Merge pull request #3326 from zeroclaw-labs/work-issues/2978-tool-call-dedup-exempt

feat(agent): add tool_call_dedup_exempt config to bypass within-turn dedup
This commit is contained in:
SimianAstronaut7 2026-03-12 16:58:41 +00:00 committed by GitHub
commit d02fbf2d76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 185 additions and 1 deletions

View File

@ -70,6 +70,7 @@ Lưu ý cho người dùng container:
| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |
| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
| `tool_call_dedup_exempt` | `[]` | Tên tool được miễn kiểm tra trùng lặp trong cùng một lượt |
Lưu ý:
@ -77,6 +78,7 @@ Lưu ý:
- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations (<value>)`.
- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
- `tool_call_dedup_exempt` nhận mảng tên tool chính xác. Các tool trong danh sách được phép gọi nhiều lần với cùng tham số trong một lượt. Ví dụ: `tool_call_dedup_exempt = ["browser"]`.
## `[agents.<name>]`

View File

@ -81,6 +81,7 @@ Operational note for container users:
| `max_history_messages` | `50` | Maximum conversation history messages retained per session |
| `parallel_tools` | `false` | Enable parallel tool execution within a single iteration |
| `tool_dispatcher` | `auto` | Tool dispatch strategy |
| `tool_call_dedup_exempt` | `[]` | Tool names exempt from within-turn duplicate-call suppression |
Notes:
@ -88,6 +89,7 @@ Notes:
- If a channel message exceeds this value, the runtime returns: `Agent exceeded maximum tool iterations (<value>)`.
- In CLI, gateway, and channel tool loops, multiple independent tool calls are executed concurrently by default when the pending calls do not require approval gating; result order remains stable.
- `parallel_tools` applies to the `Agent::turn()` API surface. It does not gate the runtime loop used by CLI, gateway, or channel handlers.
- `tool_call_dedup_exempt` accepts an array of exact tool names. Tools listed here are allowed to be called multiple times with identical arguments in the same turn, bypassing the dedup check. Example: `tool_call_dedup_exempt = ["browser"]`.
## `[security.otp]`

View File

@ -70,6 +70,7 @@ Lưu ý cho người dùng container:
| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |
| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
| `tool_call_dedup_exempt` | `[]` | Tên tool được miễn kiểm tra trùng lặp trong cùng một lượt |
Lưu ý:
@ -77,6 +78,7 @@ Lưu ý:
- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations (<value>)`.
- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
- `tool_call_dedup_exempt` nhận mảng tên tool chính xác. Các tool trong danh sách được phép gọi nhiều lần với cùng tham số trong một lượt. Ví dụ: `tool_call_dedup_exempt = ["browser"]`.
## `[agents.<name>]`

View File

@ -1966,6 +1966,7 @@ pub(crate) async fn agent_turn(
None,
None,
&[],
&[],
)
.await
}
@ -2165,6 +2166,7 @@ pub(crate) async fn run_tool_call_loop(
on_delta: Option<tokio::sync::mpsc::Sender<String>>,
hooks: Option<&crate::hooks::HookRunner>,
excluded_tools: &[String],
dedup_exempt_tools: &[String],
) -> Result<String> {
let max_iterations = if max_tool_iterations == 0 {
DEFAULT_MAX_TOOL_ITERATIONS
@ -2574,7 +2576,8 @@ pub(crate) async fn run_tool_call_loop(
}
let signature = tool_call_signature(&tool_name, &tool_args);
if !seen_tool_signatures.insert(signature) {
let dedup_exempt = dedup_exempt_tools.iter().any(|e| e == &tool_name);
if !dedup_exempt && !seen_tool_signatures.insert(signature) {
let duplicate = format!(
"Skipped duplicate tool call '{tool_name}' with identical arguments in this turn."
);
@ -3118,6 +3121,7 @@ pub async fn run(
None,
None,
&[],
&config.agent.tool_call_dedup_exempt,
)
.await?;
final_output = response.clone();
@ -3240,6 +3244,7 @@ pub async fn run(
None,
None,
&[],
&config.agent.tool_call_dedup_exempt,
)
.await
{
@ -3785,6 +3790,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect_err("provider without vision support should fail");
@ -3831,6 +3837,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect_err("oversized payload must fail");
@ -3871,6 +3878,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect("valid multimodal payload should pass");
@ -3997,6 +4005,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect("parallel execution should complete");
@ -4066,6 +4075,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect("loop should finish after deduplicating repeated calls");
@ -4085,6 +4095,142 @@ mod tests {
assert!(tool_results.content.contains("Skipped duplicate tool call"));
}
#[tokio::test]
async fn run_tool_call_loop_dedup_exempt_allows_repeated_calls() {
let provider = ScriptedProvider::from_text_responses(vec![
r#"<tool_call>
{"name":"count_tool","arguments":{"value":"A"}}
</tool_call>
<tool_call>
{"name":"count_tool","arguments":{"value":"A"}}
</tool_call>"#,
"done",
]);
let invocations = Arc::new(AtomicUsize::new(0));
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
"count_tool",
Arc::clone(&invocations),
))];
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("run tool calls"),
];
let observer = NoopObserver;
let exempt = vec!["count_tool".to_string()];
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"cli",
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
&exempt,
)
.await
.expect("loop should finish with exempt tool executing twice");
assert_eq!(result, "done");
assert_eq!(
invocations.load(Ordering::SeqCst),
2,
"exempt tool should execute both duplicate calls"
);
let tool_results = history
.iter()
.find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
.expect("prompt-mode tool result payload should be present");
assert!(
!tool_results.content.contains("Skipped duplicate tool call"),
"exempt tool calls should not be suppressed"
);
}
#[tokio::test]
async fn run_tool_call_loop_dedup_exempt_only_affects_listed_tools() {
let provider = ScriptedProvider::from_text_responses(vec![
r#"<tool_call>
{"name":"count_tool","arguments":{"value":"A"}}
</tool_call>
<tool_call>
{"name":"count_tool","arguments":{"value":"A"}}
</tool_call>
<tool_call>
{"name":"other_tool","arguments":{"value":"B"}}
</tool_call>
<tool_call>
{"name":"other_tool","arguments":{"value":"B"}}
</tool_call>"#,
"done",
]);
let count_invocations = Arc::new(AtomicUsize::new(0));
let other_invocations = Arc::new(AtomicUsize::new(0));
let tools_registry: Vec<Box<dyn Tool>> = vec![
Box::new(CountingTool::new(
"count_tool",
Arc::clone(&count_invocations),
)),
Box::new(CountingTool::new(
"other_tool",
Arc::clone(&other_invocations),
)),
];
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("run tool calls"),
];
let observer = NoopObserver;
let exempt = vec!["count_tool".to_string()];
let _result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"cli",
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
&exempt,
)
.await
.expect("loop should complete");
assert_eq!(
count_invocations.load(Ordering::SeqCst),
2,
"exempt tool should execute both calls"
);
assert_eq!(
other_invocations.load(Ordering::SeqCst),
1,
"non-exempt tool should still be deduped"
);
}
#[tokio::test]
async fn run_tool_call_loop_native_mode_preserves_fallback_tool_call_ids() {
let provider = ScriptedProvider::from_text_responses(vec![
@ -4122,6 +4268,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect("native fallback id flow should complete");

View File

@ -290,6 +290,7 @@ struct ChannelRuntimeContext {
multimodal: crate::config::MultimodalConfig,
hooks: Option<Arc<crate::hooks::HookRunner>>,
non_cli_excluded_tools: Arc<Vec<String>>,
tool_call_dedup_exempt: Arc<Vec<String>>,
model_routes: Arc<Vec<crate::config::ModelRouteConfig>>,
}
@ -1927,6 +1928,7 @@ async fn process_channel_message(
} else {
ctx.non_cli_excluded_tools.as_ref()
},
ctx.tool_call_dedup_exempt.as_ref(),
),
) => LlmExecutionResult::Completed(result),
};
@ -3523,6 +3525,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
None
},
non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()),
tool_call_dedup_exempt: Arc::new(config.agent.tool_call_dedup_exempt.clone()),
model_routes: Arc::new(config.model_routes.clone()),
});
@ -3753,6 +3756,7 @@ mod tests {
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
};
@ -3803,6 +3807,7 @@ mod tests {
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
};
@ -3856,6 +3861,7 @@ mod tests {
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
};
@ -4330,6 +4336,7 @@ BTC is currently around $65,000 based on latest tool output."#
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
model_routes: Arc::new(Vec::new()),
@ -4391,6 +4398,7 @@ BTC is currently around $65,000 based on latest tool output."#
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
model_routes: Arc::new(Vec::new()),
@ -4468,6 +4476,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -4528,6 +4537,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -4598,6 +4608,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -4688,6 +4699,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -4760,6 +4772,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -4847,6 +4860,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -4919,6 +4933,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -4981,6 +4996,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -5154,6 +5170,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -5235,6 +5252,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -5328,6 +5346,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -5403,6 +5422,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -5463,6 +5483,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -6021,6 +6042,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -6107,6 +6129,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -6193,6 +6216,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -6743,6 +6767,7 @@ This is an example JSON object for profile settings."#;
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@ -6810,6 +6835,7 @@ This is an example JSON object for profile settings."#;
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});

View File

@ -618,6 +618,9 @@ pub struct AgentConfig {
/// Tool dispatch strategy (e.g. `"auto"`). Default: `"auto"`.
#[serde(default = "default_agent_tool_dispatcher")]
pub tool_dispatcher: String,
/// Tools exempt from the within-turn duplicate-call dedup check. Default: `[]`.
#[serde(default)]
pub tool_call_dedup_exempt: Vec<String>,
}
fn default_agent_max_tool_iterations() -> usize {
@ -640,6 +643,7 @@ impl Default for AgentConfig {
max_history_messages: default_agent_max_history_messages(),
parallel_tools: false,
tool_dispatcher: default_agent_tool_dispatcher(),
tool_call_dedup_exempt: Vec::new(),
}
}
}

View File

@ -411,6 +411,7 @@ impl DelegateTool {
None,
None,
&[],
&[],
),
)
.await;