Skip to content

Commit b3296ed

Browse files
author
SqlRush
committed
Filter deferred tools during ToolSearch
1 parent a89f2ae commit b3296ed

6 files changed

Lines changed: 247 additions & 30 deletions

File tree

cmd/claude/main_test.go

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -87,20 +87,14 @@ func TestRunPrintSendsPromptAndPrintsAssistantText(t *testing.T) {
8787
t.Fatalf("max_tokens = %#v", requestBody["max_tokens"])
8888
}
8989
messages, ok := requestBody["messages"].([]any)
90-
if !ok || len(messages) != 1 {
90+
if !ok || len(messages) < 1 {
9191
t.Fatalf("messages = %#v", requestBody["messages"])
9292
}
93-
message, ok := messages[0].(map[string]any)
94-
if !ok || message["role"] != "user" {
95-
t.Fatalf("message = %#v", messages[0])
93+
if !hasAvailableDeferredToolsMessage(messages) {
94+
t.Fatalf("missing deferred tools message: %#v", messages)
9695
}
97-
content, ok := message["content"].([]any)
98-
if !ok || len(content) != 1 {
99-
t.Fatalf("content = %#v", message["content"])
100-
}
101-
block, ok := content[0].(map[string]any)
102-
if !ok || block["type"] != "text" || block["text"] != "say hello" {
103-
t.Fatalf("block = %#v", content[0])
96+
if got := messageTextAt(t, messages, 0); got != "say hello" {
97+
t.Fatalf("prompt = %q", got)
10498
}
10599
tools, ok := requestBody["tools"].([]any)
106100
if !ok || len(tools) == 0 {
@@ -1049,8 +1043,7 @@ func TestRunPrintReadsPromptFromStdinAndSettingsModel(t *testing.T) {
10491043
t.Fatalf("model = %#v", requestBody["model"])
10501044
}
10511045
messages := requestBody["messages"].([]any)
1052-
content := messages[0].(map[string]any)["content"].([]any)
1053-
if got := content[0].(map[string]any)["text"]; got != "from stdin" {
1046+
if got := messageTextAt(t, messages, 0); got != "from stdin" {
10541047
t.Fatalf("prompt = %#v", got)
10551048
}
10561049
}
@@ -2889,22 +2882,10 @@ func TestRunPrintStreamJSONIncludesToolProgress(t *testing.T) {
28892882
t.Fatalf("exit = %d stderr=%s stdout=%s", code, stderr.String(), stdout.String())
28902883
}
28912884
messages, ok := requestBody["messages"].([]any)
2892-
if !ok || len(messages) != 1 {
2885+
if !ok || len(messages) < 1 {
28932886
t.Fatalf("messages = %#v", requestBody["messages"])
28942887
}
2895-
message, ok := messages[0].(map[string]any)
2896-
if !ok {
2897-
t.Fatalf("message = %#v", messages[0])
2898-
}
2899-
content, ok := message["content"].([]any)
2900-
if !ok || len(content) != 1 {
2901-
t.Fatalf("content = %#v", message["content"])
2902-
}
2903-
block, ok := content[0].(map[string]any)
2904-
if !ok {
2905-
t.Fatalf("block = %#v", content[0])
2906-
}
2907-
prompt, _ := block["text"].(string)
2888+
prompt := messageTextAt(t, messages, 0)
29082889
if !strings.Contains(prompt, "stream prompt") || !strings.Contains(prompt, "stream hook context") {
29092890
t.Fatalf("prompt = %q", prompt)
29102891
}
@@ -3937,15 +3918,53 @@ func writeTestTranscript(t *testing.T, path string, sessionID contracts.ID, user
39373918

39383919
func messageTextAt(t *testing.T, requestMessages []any, index int) string {
39393920
t.Helper()
3940-
if index >= len(requestMessages) {
3921+
filtered := nonDeferredToolMessages(requestMessages)
3922+
if index >= len(filtered) {
39413923
t.Fatalf("messages = %#v", requestMessages)
39423924
}
3943-
message := requestMessages[index].(map[string]any)
3925+
message := filtered[index].(map[string]any)
39443926
content := message["content"].([]any)
39453927
block := content[0].(map[string]any)
39463928
return block["text"].(string)
39473929
}
39483930

3931+
func nonDeferredToolMessages(requestMessages []any) []any {
3932+
filtered := make([]any, 0, len(requestMessages))
3933+
for _, message := range requestMessages {
3934+
if isAvailableDeferredToolsMessage(message) {
3935+
continue
3936+
}
3937+
filtered = append(filtered, message)
3938+
}
3939+
return filtered
3940+
}
3941+
3942+
func hasAvailableDeferredToolsMessage(requestMessages []any) bool {
3943+
for _, message := range requestMessages {
3944+
if isAvailableDeferredToolsMessage(message) {
3945+
return true
3946+
}
3947+
}
3948+
return false
3949+
}
3950+
3951+
func isAvailableDeferredToolsMessage(message any) bool {
3952+
item, ok := message.(map[string]any)
3953+
if !ok || item["role"] != "user" {
3954+
return false
3955+
}
3956+
content, ok := item["content"].([]any)
3957+
if !ok || len(content) == 0 {
3958+
return false
3959+
}
3960+
block, ok := content[0].(map[string]any)
3961+
if !ok {
3962+
return false
3963+
}
3964+
text, _ := block["text"].(string)
3965+
return strings.HasPrefix(text, "<available-deferred-tools>\n")
3966+
}
3967+
39493968
func containsAnyString(values []any, want string) bool {
39503969
for _, value := range values {
39513970
if text, ok := value.(string); ok && text == want {

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ M8/M2 补充:conversation `BuildRequest` 现在会扫描历史 `tool_result.co
197197

198198
M8/M6 补充:compact plan 现在会把 compact 前已发现的 `tool_reference` 名称快照进 `compactMetadata.preCompactDiscoveredTools`,session transcript alias/resume 转换会保留该 metadata,conversation `BuildRequest` 可在 tool-result 消息被 summary 替换后继续从 compact boundary 恢复已发现工具并取消 `defer_loading`。完整官方 compact/snipping 边界策略仍未完成。
199199

200+
M8/M2 补充:当 `ToolSearch` 可用且存在 deferred 工具时,conversation request 现在会按官方 dynamic tool loading 过滤请求工具:未发现 deferred 工具不发送 schema,已发现 deferred 工具作为 loaded tool 发送,`ToolSearch` 保持可调用,并在 API messages 前置 `<available-deferred-tools>` 名称列表;没有 deferred 工具时会从请求中移除 `ToolSearch`
201+
200202
M8/M2 补充:Anthropic request tool 转换现在会保留 contract 的 `strict``eager_input_streaming``cache_control``should_defer`,将 deferred 工具序列化为 API `defer_loading`,并用 `always_load` 覆盖 deferred hint;API tool description 会按 description、prompt、searchHint 顺序 fallback,conversation runner 构造请求时会把 `Task` 等 deferred tool 的 strict/defer_loading 元数据带到最终请求。完整 deferred/lazy tool discovery 仍未完成。
201203

202204
M8/M5 补充:tool executor 现在会在未通过 `ToolSearch` 发现的 deferred 工具输入 schema 校验失败时追加 schema-not-sent 恢复提示,指导模型先调用 `ToolSearch``select:<tool>` 再重试;conversation runner 会把当前 turn messages 放进工具 metadata,提示判断兼容普通 `tool_reference` 结果和 compact boundary 已发现工具快照。

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ test/parity/ # golden tests against TS/official behavior
293293
- 本轮补充:新增基础 `ToolSearch` tool 并注册到默认内置工具集,executor 会把当前 tool registry 注入工具 metadata;`ToolSearch` 可按 name、alias、description、prompt、search hint 和 input/output schema 字段搜索当前工具定义,支持 `select:ToolA,ToolB` 直接选择并返回 `tool_reference` content,返回 BM25/select structured results 和 read-only/concurrency/destructive、input/output schema 以及 `should_defer`/`always_load`/`requires_interaction`/`strict`/cache/eager 等请求提示元数据,并兼容 query/limit 相邻输入别名。deferred/lazy tool discovery 仍未宣称完成。
294294
- 本轮补充:conversation `BuildRequest` 会扫描历史 `tool_result.content` 中的 `tool_reference`,并把已发现工具在后续 API request 中作为 loaded tool 发送,不再携带 `defer_loading`;扫描兼容运行时 `ToolReference` 值和 transcript/JSON 解码后的 map 形态。完整官方 tool-reference expansion/filtering 仍未宣称完成。
295295
- 本轮补充:compact plan 会把 compact 前已发现的 `tool_reference` 名称快照进 `compactMetadata.preCompactDiscoveredTools`,session transcript alias/resume 转换会保留该 metadata,conversation `BuildRequest` 可在 tool-result 消息被 summary 替换后继续从 compact boundary 恢复已发现工具并取消 `defer_loading`。完整官方 compact/snipping 边界策略仍未宣称完成。
296+
- 本轮补充:当 request registry 同时存在 `ToolSearch` 和 deferred 工具时,conversation request 会按官方 dynamic tool loading 过滤工具列表:未发现 deferred 工具不再发送 schema,已发现 deferred 工具作为 loaded tool 发送,`ToolSearch` 保持可调用,并在首条 API user message 前注入 `<available-deferred-tools>` 名称列表;没有 deferred 工具时会从请求中移除 `ToolSearch`
296297
- 本轮补充:Anthropic request tool 转换会保留 contract 的 `strict``eager_input_streaming``cache_control``should_defer`,把 deferred 工具序列化为 API `defer_loading`,并让 `always_load` 覆盖 deferred hint;API tool description 会按 description、prompt、searchHint 顺序 fallback,conversation runner 构造请求时会保留 `Task` 等 deferred tool 的 strict/defer_loading 元数据。完整 deferred/lazy tool discovery 仍未宣称完成。
297298
- 本轮补充:tool executor 会在未通过 `ToolSearch` 发现的 deferred 工具发生输入 schema 校验失败时追加 schema-not-sent 恢复提示,引导模型先调用 `ToolSearch``select:<tool>` 再重试;runner 会把当前 turn messages 传入工具 metadata,提示判断兼容 `tool_reference` 结果和 compact boundary 的已发现工具快照。
298299
- 本轮补充:新增基础 slash command parser/executor,支持官方 `/command args``/mcp:tool (MCP) args` 解析;conversation runner 现在会在请求模型前展开本地项目 prompt skill slash command,生成 command metadata user message 和 meta prompt message,保留 transcript parent chain,并支持 skill `model` 覆盖本轮请求。local/local-jsx 目前只返回未实现输出且不会误发模型,command permissions attachment、forked/MCP/plugin/bundled slash command 和 UI wiring 仍未宣称完成。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Anthropic API 和 conversation:
9191
- A basic `ToolSearch` tool is now registered with the default built-in tool set. The executor exposes the current registry through tool metadata, and `ToolSearch` can search available definitions by name, alias, description, prompt, search hint, and input/output schema fields, supports `select:ToolA,ToolB` direct selection, and returns tool-reference content plus BM25/select structured results with read-only/concurrency/destructive, input/output schema, deferred/loading/request-hint metadata, and query/limit aliases.
9292
- Conversation request construction now scans previous `tool_result` blocks for `tool_reference` content from `ToolSearch`; referenced tools are copied into the next API request without `defer_loading`, including both in-memory `ToolReference` values and decoded transcript map forms.
9393
- Compact planning now snapshots discovered `tool_reference` names into `compactMetadata.preCompactDiscoveredTools`; resume/transcript conversion preserves that metadata and request construction reloads those tools after compacted `tool_result` messages have been summarized away.
94+
- When `ToolSearch` is available and deferred tools exist, request construction now follows dynamic tool loading: undiscovered deferred schemas are omitted from `tools`, discovered deferred tools are loaded, `ToolSearch` stays callable, and an `<available-deferred-tools>` user meta message advertises the deferred tool names; if no deferred tools exist, `ToolSearch` is omitted from the request.
9495
- Anthropic request tool conversion now preserves contract-level `strict`, `eager_input_streaming`, `cache_control`, and API-level `defer_loading` hints from deferred tool definitions, with `always_load` overriding `should_defer`; it also falls back from `description` to `prompt` to `searchHint` when building API tool descriptions. Runner request construction preserves these fields for tools such as `Task`.
9596
- Tool validation now appends an official-style schema-not-sent recovery hint when an undiscovered deferred tool is invoked with invalid typed input and `ToolSearch` is available, including compact-boundary discovered-tool metadata so already loaded tools do not get a false hint.
9697
- Basic slash prompt command invocation is now wired for local project prompt skills: `/skill args` is parsed before the model request, converted into official-shaped command metadata plus expanded meta prompt messages, appended to transcript parent chains, and can override the turn model from skill frontmatter.

internal/conversation/request.go

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package conversation
22

33
import (
4+
"sort"
45
"strings"
56
"time"
67

@@ -48,14 +49,44 @@ func (r Runner) buildRequest(history []contracts.Message, model string, relevant
4849
if err != nil {
4950
return anthropic.Request{}, err
5051
}
51-
definitions = applyDiscoveredToolReferences(definitions, history)
52+
definitions, deferredToolNames := filterToolSearchDefinitions(definitions, history)
53+
if len(deferredToolNames) > 0 {
54+
request.Messages = prependAvailableDeferredToolsMessage(request.Messages, deferredToolNames)
55+
}
5256
if len(definitions) > 0 {
5357
request.Tools = anthropic.ToolsFromContracts(definitions)
5458
}
5559
}
5660
return request, nil
5761
}
5862

63+
func filterToolSearchDefinitions(definitions []contracts.ToolDefinition, history []contracts.Message) ([]contracts.ToolDefinition, []string) {
64+
if len(definitions) == 0 {
65+
return definitions, nil
66+
}
67+
if !hasToolSearchDefinition(definitions) {
68+
return applyDiscoveredToolReferences(definitions, history), nil
69+
}
70+
deferredNames := deferredToolNames(definitions)
71+
if len(deferredNames) == 0 {
72+
return withoutToolSearchDefinition(definitions), nil
73+
}
74+
discovered := discoveredToolReferenceNames(history)
75+
out := make([]contracts.ToolDefinition, 0, len(definitions))
76+
for _, definition := range definitions {
77+
if isToolSearchDefinition(definition) || !toolDefinitionDeferred(definition) {
78+
out = append(out, definition)
79+
continue
80+
}
81+
if toolDefinitionDiscovered(definition, discovered) {
82+
definition.AlwaysLoad = true
83+
definition.ShouldDefer = false
84+
out = append(out, definition)
85+
}
86+
}
87+
return out, deferredNames
88+
}
89+
5990
func applyDiscoveredToolReferences(definitions []contracts.ToolDefinition, history []contracts.Message) []contracts.ToolDefinition {
6091
discovered := discoveredToolReferenceNames(history)
6192
if len(discovered) == 0 || len(definitions) == 0 {
@@ -72,6 +103,63 @@ func applyDiscoveredToolReferences(definitions []contracts.ToolDefinition, histo
72103
return out
73104
}
74105

106+
func prependAvailableDeferredToolsMessage(messages []contracts.APIMessage, toolNames []string) []contracts.APIMessage {
107+
if len(toolNames) == 0 {
108+
return messages
109+
}
110+
content := "<available-deferred-tools>\n" + strings.Join(toolNames, "\n") + "\n</available-deferred-tools>"
111+
out := make([]contracts.APIMessage, 0, len(messages)+1)
112+
out = append(out, contracts.APIMessage{Role: "user", Content: []contracts.ContentBlock{contracts.NewTextBlock(content)}})
113+
out = append(out, messages...)
114+
return out
115+
}
116+
117+
func deferredToolNames(definitions []contracts.ToolDefinition) []string {
118+
var names []string
119+
for _, definition := range definitions {
120+
if toolDefinitionDeferred(definition) {
121+
names = append(names, definition.Name)
122+
}
123+
}
124+
sort.Strings(names)
125+
return names
126+
}
127+
128+
func toolDefinitionDeferred(definition contracts.ToolDefinition) bool {
129+
return definition.ShouldDefer && !definition.AlwaysLoad && !isToolSearchDefinition(definition)
130+
}
131+
132+
func hasToolSearchDefinition(definitions []contracts.ToolDefinition) bool {
133+
for _, definition := range definitions {
134+
if isToolSearchDefinition(definition) {
135+
return true
136+
}
137+
}
138+
return false
139+
}
140+
141+
func withoutToolSearchDefinition(definitions []contracts.ToolDefinition) []contracts.ToolDefinition {
142+
out := make([]contracts.ToolDefinition, 0, len(definitions))
143+
for _, definition := range definitions {
144+
if !isToolSearchDefinition(definition) {
145+
out = append(out, definition)
146+
}
147+
}
148+
return out
149+
}
150+
151+
func isToolSearchDefinition(definition contracts.ToolDefinition) bool {
152+
if strings.EqualFold(strings.TrimSpace(definition.Name), "ToolSearch") {
153+
return true
154+
}
155+
for _, alias := range definition.Aliases {
156+
if strings.EqualFold(strings.TrimSpace(alias), "ToolSearch") {
157+
return true
158+
}
159+
}
160+
return false
161+
}
162+
75163
func toolDefinitionDiscovered(definition contracts.ToolDefinition, discovered map[string]struct{}) bool {
76164
if _, ok := discovered[strings.ToLower(definition.Name)]; ok {
77165
return true

internal/conversation/run_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
telemetrypkg "ccgo/internal/telemetry"
3737
"ccgo/internal/tool"
3838
filetools "ccgo/internal/tools/file"
39+
searchtools "ccgo/internal/tools/searchtools"
3940
skilltools "ccgo/internal/tools/skill"
4041
tasktools "ccgo/internal/tools/task"
4142
)
@@ -7864,6 +7865,15 @@ func requestHasTool(request anthropic.Request, name string) bool {
78647865
return false
78657866
}
78667867

7868+
func requestTool(request anthropic.Request, name string) anthropic.ToolDefinition {
7869+
for _, definition := range request.Tools {
7870+
if definition.Name == name {
7871+
return definition
7872+
}
7873+
}
7874+
return anthropic.ToolDefinition{}
7875+
}
7876+
78677877
func bridgeManifestHasCommand(manifest bridgepkg.Manifest, name string) bool {
78687878
for _, command := range manifest.Commands {
78697879
if command.Name == name {
@@ -7978,6 +7988,102 @@ func TestBuildRequestPreservesDeferredToolMetadata(t *testing.T) {
79787988
}
79797989
}
79807990

7991+
func TestBuildRequestWithToolSearchOmitsUndiscoveredDeferredTools(t *testing.T) {
7992+
registry, err := tool.NewRegistry(tasktools.NewTaskTool(), searchtools.NewToolSearchTool())
7993+
if err != nil {
7994+
t.Fatal(err)
7995+
}
7996+
runner := Runner{
7997+
Tools: tool.NewExecutor(registry),
7998+
Model: "sonnet",
7999+
MaxTokens: 100,
8000+
}
8001+
req, err := runner.BuildRequest([]contracts.Message{messages.UserText("hi")}, "sonnet")
8002+
if err != nil {
8003+
t.Fatal(err)
8004+
}
8005+
if len(req.Tools) != 1 || req.Tools[0].Name != "ToolSearch" {
8006+
t.Fatalf("tools = %#v", req.Tools)
8007+
}
8008+
if len(req.Messages) != 2 {
8009+
t.Fatalf("messages = %#v", req.Messages)
8010+
}
8011+
deferred := req.Messages[0]
8012+
if deferred.Role != "user" || !strings.Contains(deferred.Content[0].Text, "<available-deferred-tools>\nTask\n</available-deferred-tools>") {
8013+
t.Fatalf("deferred tools message = %#v", deferred)
8014+
}
8015+
if got := req.Messages[1].Content[0].Text; got != "hi" {
8016+
t.Fatalf("original user message = %q", got)
8017+
}
8018+
}
8019+
8020+
func TestBuildRequestWithToolSearchLoadsDiscoveredDeferredTools(t *testing.T) {
8021+
registry, err := tool.NewRegistry(tasktools.NewTaskTool(), searchtools.NewToolSearchTool())
8022+
if err != nil {
8023+
t.Fatal(err)
8024+
}
8025+
runner := Runner{
8026+
Tools: tool.NewExecutor(registry),
8027+
Model: "sonnet",
8028+
MaxTokens: 100,
8029+
}
8030+
history := []contracts.Message{
8031+
messages.UserText("find task"),
8032+
{
8033+
Type: contracts.MessageUser,
8034+
Content: []contracts.ContentBlock{{
8035+
Type: contracts.ContentToolResult,
8036+
ToolUseID: "toolu_search",
8037+
Content: []contracts.ToolReference{contracts.NewToolReference("Task")},
8038+
}},
8039+
},
8040+
}
8041+
req, err := runner.BuildRequest(history, "sonnet")
8042+
if err != nil {
8043+
t.Fatal(err)
8044+
}
8045+
if !requestHasTool(req, "ToolSearch") || !requestHasTool(req, "Task") || len(req.Tools) != 2 {
8046+
t.Fatalf("tools = %#v", req.Tools)
8047+
}
8048+
taskTool := requestTool(req, "Task")
8049+
if taskTool.DeferLoading {
8050+
t.Fatalf("discovered task defer_loading = true")
8051+
}
8052+
if len(req.Messages) < 1 || !strings.Contains(req.Messages[0].Content[0].Text, "<available-deferred-tools>\nTask\n</available-deferred-tools>") {
8053+
t.Fatalf("messages = %#v", req.Messages)
8054+
}
8055+
}
8056+
8057+
func TestBuildRequestWithToolSearchButNoDeferredToolsOmitsToolSearch(t *testing.T) {
8058+
registry, err := tool.NewRegistry(
8059+
tool.FuncTool{DefinitionValue: contracts.ToolDefinition{
8060+
Name: "Read",
8061+
Description: "read a file",
8062+
ReadOnly: true,
8063+
InputSchema: contracts.JSONSchema{"type": "object"},
8064+
}},
8065+
searchtools.NewToolSearchTool(),
8066+
)
8067+
if err != nil {
8068+
t.Fatal(err)
8069+
}
8070+
runner := Runner{
8071+
Tools: tool.NewExecutor(registry),
8072+
Model: "sonnet",
8073+
MaxTokens: 100,
8074+
}
8075+
req, err := runner.BuildRequest([]contracts.Message{messages.UserText("hi")}, "sonnet")
8076+
if err != nil {
8077+
t.Fatal(err)
8078+
}
8079+
if len(req.Tools) != 1 || req.Tools[0].Name != "Read" {
8080+
t.Fatalf("tools = %#v", req.Tools)
8081+
}
8082+
if len(req.Messages) != 1 || req.Messages[0].Content[0].Text != "hi" {
8083+
t.Fatalf("messages = %#v", req.Messages)
8084+
}
8085+
}
8086+
79818087
func TestBuildRequestLoadsDiscoveredToolReferences(t *testing.T) {
79828088
registry, err := tool.NewRegistry(tasktools.NewTaskTool())
79838089
if err != nil {

0 commit comments

Comments
 (0)