fix(tool-search): name the deferred tools a search activation evicted

The conversation toolbox (ToolActivationCache: 8 names, 16 KiB of
serialized schemas, LRU) evicts earlier activations when a search admits
new ones. execute_tool_search_inner removed the evicted names from the
active set, but the search result carried only `tool_references` and
`unavailable_tool_references`. The model was never told. Its next request
simply lacked the tool, and #6969 describes a model calling tools that had
vanished from its catalog. The direct-call hydration path already says
what it loaded ("Tool ... was deferred and has now been loaded"). Search was
the silent one.

The result now adds `evicted_tool_references`, as typed entries with a
reason, plus the same names in metadata, but only when something was
evicted. Ordinary search payloads are byte-identical, so existing
consumers and the history of unaffected sessions do not change. Only names
that actually left the active set are listed: a tool that became eager is
filtered out by remove_evicted_cache_activations, stays callable, and is
not reported. The reason text holds for every cause activate() can evict
for: LRU, or revalidation of a removed, denied or newly-eager tool.
docs/TOOL_SURFACE.md and the zh_hans copy describe it, and record the
limit: a tool dropped by per-turn revalidation outside a search
(turn_loop.rs:1467) is still dropped silently, because no tool result
exists to carry the notice.

KV-cache effect: none on the prefix. The notice rides the search's own
tool result, which is append-only history.

Evidence:
- A throwaway scenario test (not committed) ran ten searches over ten
  deferred tools. Searches 1-8 return the old payload unchanged. Search 9
  reports alpha0 evicted, and search 10 reports alpha1. The active set ends
  as alpha2..alpha9, matching the cache.
- scripts/dev-test.sh tui tool_search tool_catalog deferred toolbox
  activation: 88 tests run, 88 passed.
- CI-form clippy (-p codewhale-tui --all-targets --all-features --locked)
  exit 0. cargo fmt --check clean. sync-changelog and check-versions OK.

The cost questions in #6969 (hysteresis, wire-byte budget, append-in-context
injection) are not addressed here.

Refs #6969

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-16 23:30:07 -07:00
1 parent f6583c3ffe
commit 3f0bcb4166
5 files changed
+53 -6

No files matched your search

+6
View File
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- When a `tool_search` activation pushes an earlier deferred tool out of the
bounded conversation toolbox (8 tools, 16 KiB of schemas), the search
result now names it under `evicted_tool_references`. Before, the tool
silently disappeared from the next request, and a model could go on
calling it. Thanks @asto18089 for the cost and correctness analysis
(Refs #6969).
- An ACP client that asks how to turn on Full Access is now told the
routes that work: start the server with `codewhale --yolo serve --acp`,
or set `yolo = true` in config.toml. The hint used to recommend
+6
View File
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- When a `tool_search` activation pushes an earlier deferred tool out of the
bounded conversation toolbox (8 tools, 16 KiB of schemas), the search
result now names it under `evicted_tool_references`. Before, the tool
silently disappeared from the next request, and a model could go on
calling it. Thanks @asto18089 for the cost and correctness analysis
(Refs #6969).
- An ACP client that asks how to turn on Full Access is now told the
routes that work: start the server with `codewhale --yolo serve --acp`,
or set `yolo = true` in config.toml. The hint used to recommend
+26 -6
View File
@@ -1543,9 +1543,15 @@ fn execute_tool_search_inner(
};
let mut cache_rejected = Vec::new();
let mut evicted = Vec::new();
if let Some(cache) = cache {
let delta = cache.activate(catalog, &discovered);
remove_evicted_cache_activations(catalog, active_tools, delta.evicted);
remove_evicted_cache_activations(catalog, active_tools, delta.evicted.iter().cloned());
evicted = delta
.evicted
.into_iter()
.filter(|name| !active_tools.contains(name))
.collect();
cache_rejected = delta.rejected;
discovered = delta.admitted;
}
@@ -1575,19 +1581,33 @@ fn execute_tool_search_inner(
})
}));
let payload = json!({
let mut payload = json!({
"type": "tool_search_tool_search_result",
"tool_references": references,
"unavailable_tool_references": unavailable_references.clone(),
});
let mut metadata = json!({
"tool_references": discovered,
"unavailable_tool_references": unavailable_references,
});
if !evicted.is_empty() {
payload["evicted_tool_references"] = evicted
.iter()
.map(|name| {
json!({
"type": "evicted_tool_reference",
"tool_name": name,
"reason": "No longer in this conversation's bounded toolbox (8 cached tools, 16KiB of added serialized schemas), so it is not callable now. Search for it again before using it."
})
})
.collect();
metadata["evicted_tool_references"] = json!(evicted);
}
Ok(ToolResult {
content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()),
success: true,
metadata: Some(json!({
"tool_references": discovered,
"unavailable_tool_references": unavailable_references,
})),
metadata: Some(metadata),
})
}
+9
View File
@@ -194,6 +194,15 @@ entry against the current catalog and policy before advertising it again. A
session sync clears it. The cache cannot resurrect a removed, denied, or
newly-eager tool.
When a search's activation pushes a previously activated tool out of the
cache, that search's result lists it under `evicted_tool_references`, so the
model is told the tool is no longer callable rather than finding it missing
from the next request. Only tools that actually left the active set are listed;
a tool that became eager stays callable and is not reported. Known limit: a
tool dropped by the per-turn revalidation (removed, denied, or no longer
deferred) outside a search is still dropped silently, because there is no tool
result to carry the notice.
Each subagent gets its own policy-filtered deferred catalog, always-present
`tool_search`, and bounded activation cache. Forked messages and instructions
remain in context, but the child cache starts empty and discovers tools locally;
+6
View File
@@ -151,6 +151,12 @@ MCP 工具是动态的。连接成功的服务器会从 `~/.codewhale/mcp.json`
让每个条目对照当前目录与策略重新校验。会话同步会清空它。
缓存无法让已移除、已被拒绝或刚刚变为预加载的工具复活。
如果某次搜索的激活把之前激活的工具挤出了缓存,这次搜索的结果会在
`evicted_tool_references` 中列出它,告诉模型该工具已不可调用,而不是让它在下一次请求里
悄悄消失。只列出真正离开激活集合的工具;刚刚变为预加载的工具仍可调用,不会列出。
已知限制:在搜索之外,按回合重新校验时被丢弃的工具(已移除、已被拒绝或不再延迟加载)
仍然会被静默丢弃,因为没有工具结果可以携带这条通知。
每个子智能体都有自己的、经策略过滤的延迟目录,始终存在的 `tool_search`,
以及有界的激活缓存。分叉出来的消息和指令仍留在上下文(context)中,
但子智能体的缓存从空开始,并在本地发现工具;分叉的上下文和缓存都不能变成发现白名单。