Summary
When GetToolSpec is explicitly included in allowed_tools and collapsed tools are present, resolve_tool_manifest_policy pushes GetToolSpec into expanded_tool_names twice, causing duplicate entries in the final tool_definitions sent to the AI provider. The provider rejects the request with:
HTTP 400: Tool names must be unique.
Steps to Reproduce
- Configure the agent with
GetToolSpec explicitly in the tool allowlist
- Also include any tool whose
default_exposure is Collapsed (e.g. WebFetch, WebSearch, Git, GetFileDiff)
- Send a request → AI provider returns
Tool names must be unique
This is commonly hit when enabling a large set of tools (e.g. all ~60 available) because the conditions are easily met.
Root Cause
src/crates/execution/tool-contracts/src/framework.rs, function resolve_tool_manifest_policy:
- Lines 240–253: iterates
allowed_tools, pushes GetToolSpec into expanded_tool_names once (it has Expanded default exposure).
- Lines 262–267: when collapsed tools exist, unconditionally pushes
GetToolSpec into expanded_tool_names again, without a dedup check.
Note that allowed_tool_names already has a dedup guard (line 256–258), but expanded_tool_names does not — an asymmetric omission.
Known Workaround
Manually exclude GetToolSpec from the tool list. The system auto-adds it when collapsed tools are present.
Related Test Coverage (confirms the bug)
Two tests currently assert the duplicate as expected behavior:
| Test |
File |
Current Expected |
product_manifest_preserves_explicit_get_tool_spec_runtime_contract |
catalog.rs:706 |
["WebFetch", "GetToolSpec", "GetToolSpec"] |
tool_manifest_policy_preserves_explicit_get_tool_spec_duplicate_runtime_contract |
tool_contracts.rs:1569 |
[GET_TOOL_SPEC, GET_TOOL_SPEC] |
Proposed Fix
Add dedup check before pushing to expanded_tool_names, mirroring the existing guard on allowed_tool_names:
if tool_snapshot
.iter()
.any(|tool| tool.name == get_tool_spec_tool_name)
+ && !expanded_tool_names
+ .iter()
+ .any(|name| name == get_tool_spec_tool_name)
{
expanded_tool_names.push(get_tool_spec_tool_name.to_string());
}
Tests updated: the two tests above should assert single occurrence instead of duplicate.
Verification:
cargo check --workspace — pass
cargo test -p bitfun-agent-tools — 143 pass
cargo test -p bitfun-core -- catalog — 22 pass
cargo test -p bitfun-agent-runtime --test post_call_hook_execution_contracts — 3 pass
pnpm run i18n:contract:test — 37 pass
Summary
When
GetToolSpecis explicitly included inallowed_toolsand collapsed tools are present,resolve_tool_manifest_policypushesGetToolSpecintoexpanded_tool_namestwice, causing duplicate entries in the finaltool_definitionssent to the AI provider. The provider rejects the request with:Steps to Reproduce
GetToolSpecexplicitly in the tool allowlistdefault_exposureisCollapsed(e.g.WebFetch,WebSearch,Git,GetFileDiff)Tool names must be uniqueThis is commonly hit when enabling a large set of tools (e.g. all ~60 available) because the conditions are easily met.
Root Cause
src/crates/execution/tool-contracts/src/framework.rs, functionresolve_tool_manifest_policy:allowed_tools, pushesGetToolSpecintoexpanded_tool_namesonce (it hasExpandeddefault exposure).GetToolSpecintoexpanded_tool_namesagain, without a dedup check.Note that
allowed_tool_namesalready has a dedup guard (line 256–258), butexpanded_tool_namesdoes not — an asymmetric omission.Known Workaround
Manually exclude
GetToolSpecfrom the tool list. The system auto-adds it when collapsed tools are present.Related Test Coverage (confirms the bug)
Two tests currently assert the duplicate as expected behavior:
product_manifest_preserves_explicit_get_tool_spec_runtime_contractcatalog.rs:706["WebFetch", "GetToolSpec", "GetToolSpec"]tool_manifest_policy_preserves_explicit_get_tool_spec_duplicate_runtime_contracttool_contracts.rs:1569[GET_TOOL_SPEC, GET_TOOL_SPEC]Proposed Fix
Add dedup check before pushing to
expanded_tool_names, mirroring the existing guard onallowed_tool_names:if tool_snapshot .iter() .any(|tool| tool.name == get_tool_spec_tool_name) + && !expanded_tool_names + .iter() + .any(|name| name == get_tool_spec_tool_name) { expanded_tool_names.push(get_tool_spec_tool_name.to_string()); }Tests updated: the two tests above should assert single occurrence instead of duplicate.
Verification:
cargo check --workspace— passcargo test -p bitfun-agent-tools— 143 passcargo test -p bitfun-core -- catalog— 22 passcargo test -p bitfun-agent-runtime --test post_call_hook_execution_contracts— 3 passpnpm run i18n:contract:test— 37 pass