Skip to content

Commit c1ebb09

Browse files
author
SqlRush
committed
Strip inactive tool references from requests
1 parent b3296ed commit c1ebb09

5 files changed

Lines changed: 221 additions & 21 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ M8/M6 补充:compact plan 现在会把 compact 前已发现的 `tool_reference
199199

200200
M8/M2 补充:当 `ToolSearch` 可用且存在 deferred 工具时,conversation request 现在会按官方 dynamic tool loading 过滤请求工具:未发现 deferred 工具不发送 schema,已发现 deferred 工具作为 loaded tool 发送,`ToolSearch` 保持可调用,并在 API messages 前置 `<available-deferred-tools>` 名称列表;没有 deferred 工具时会从请求中移除 `ToolSearch`
201201

202+
M8/M2 补充:当本次 request 未启用 `ToolSearch` 时,conversation request 现在会从 API user `tool_result.content` 中剥离历史 `tool_reference` blocks;纯 reference 结果会替换为官方占位文本 `[Tool references removed - tool search not enabled]`,剥离发生在 discovered-tool 扫描之后,避免影响后续 loaded 工具恢复。
203+
202204
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 仍未完成。
203205

204206
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
@@ -294,6 +294,7 @@ test/parity/ # golden tests against TS/official behavior
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 边界策略仍未宣称完成。
296296
- 本轮补充:当 request registry 同时存在 `ToolSearch` 和 deferred 工具时,conversation request 会按官方 dynamic tool loading 过滤工具列表:未发现 deferred 工具不再发送 schema,已发现 deferred 工具作为 loaded tool 发送,`ToolSearch` 保持可调用,并在首条 API user message 前注入 `<available-deferred-tools>` 名称列表;没有 deferred 工具时会从请求中移除 `ToolSearch`
297+
- 本轮补充:当本次 request 未启用 `ToolSearch` 时,API message normalization 会从历史 user `tool_result.content` 中剥离 `tool_reference` blocks;纯 reference 结果会替换成官方占位文本 `[Tool references removed - tool search not enabled]`,但剥离发生在 discovered-tool 扫描之后,不影响后续启用 ToolSearch 时恢复 loaded 工具。
297298
- 本轮补充: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 仍未宣称完成。
298299
- 本轮补充:tool executor 会在未通过 `ToolSearch` 发现的 deferred 工具发生输入 schema 校验失败时追加 schema-not-sent 恢复提示,引导模型先调用 `ToolSearch``select:<tool>` 再重试;runner 会把当前 turn messages 传入工具 metadata,提示判断兼容 `tool_reference` 结果和 compact boundary 的已发现工具快照。
299300
- 本轮补充:新增基础 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
@@ -92,6 +92,7 @@ Anthropic API 和 conversation:
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.
9494
- 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.
95+
- When ToolSearch is inactive for a request, API message normalization strips historical `tool_reference` blocks from user `tool_result` content and replaces pure-reference results with the official placeholder text, while preserving history scanning before stripping so discovered tools can still be loaded when ToolSearch is active.
9596
- 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`.
9697
- 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.
9798
- 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: 108 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,40 +36,47 @@ func (r Runner) buildRequest(history []contracts.Message, model string, relevant
3636
}
3737
}
3838
history = memory.ExpandRelevantMemoryAttachments(history, time.Time{})
39+
var definitions []contracts.ToolDefinition
40+
var deferredToolNames []string
41+
toolSearchActive := false
42+
if r.Tools.Registry != nil {
43+
defs, err := r.Tools.Registry.Definitions(toolPromptContext(r))
44+
if err != nil {
45+
return anthropic.Request{}, err
46+
}
47+
definitions, deferredToolNames, toolSearchActive = filterToolSearchDefinitions(defs, history)
48+
}
49+
apiMessages := msgs.NormalizeForAPI(history)
50+
if !toolSearchActive {
51+
apiMessages = stripToolReferenceBlocksFromAPIMessages(apiMessages)
52+
}
53+
if len(deferredToolNames) > 0 {
54+
apiMessages = prependAvailableDeferredToolsMessage(apiMessages, deferredToolNames)
55+
}
3956
request := anthropic.Request{
4057
Model: model,
4158
MaxTokens: r.maxTokens(),
42-
Messages: msgs.NormalizeForAPI(history),
59+
Messages: apiMessages,
4360
}
4461
if system := r.systemPromptWithOutputStyle(); system != "" {
4562
request.System = system
4663
}
47-
if r.Tools.Registry != nil {
48-
definitions, err := r.Tools.Registry.Definitions(toolPromptContext(r))
49-
if err != nil {
50-
return anthropic.Request{}, err
51-
}
52-
definitions, deferredToolNames := filterToolSearchDefinitions(definitions, history)
53-
if len(deferredToolNames) > 0 {
54-
request.Messages = prependAvailableDeferredToolsMessage(request.Messages, deferredToolNames)
55-
}
56-
if len(definitions) > 0 {
57-
request.Tools = anthropic.ToolsFromContracts(definitions)
58-
}
64+
if len(definitions) > 0 {
65+
request.Tools = anthropic.ToolsFromContracts(definitions)
5966
}
6067
return request, nil
6168
}
6269

63-
func filterToolSearchDefinitions(definitions []contracts.ToolDefinition, history []contracts.Message) ([]contracts.ToolDefinition, []string) {
70+
func filterToolSearchDefinitions(definitions []contracts.ToolDefinition, history []contracts.Message) ([]contracts.ToolDefinition, []string, bool) {
6471
if len(definitions) == 0 {
65-
return definitions, nil
72+
return definitions, nil, false
6673
}
6774
if !hasToolSearchDefinition(definitions) {
68-
return applyDiscoveredToolReferences(definitions, history), nil
75+
return applyDiscoveredToolReferences(definitions, history), nil, false
6976
}
7077
deferredNames := deferredToolNames(definitions)
7178
if len(deferredNames) == 0 {
72-
return withoutToolSearchDefinition(definitions), nil
79+
return withoutToolSearchDefinition(definitions), nil, false
7380
}
7481
discovered := discoveredToolReferenceNames(history)
7582
out := make([]contracts.ToolDefinition, 0, len(definitions))
@@ -84,7 +91,7 @@ func filterToolSearchDefinitions(definitions []contracts.ToolDefinition, history
8491
out = append(out, definition)
8592
}
8693
}
87-
return out, deferredNames
94+
return out, deferredNames, true
8895
}
8996

9097
func applyDiscoveredToolReferences(definitions []contracts.ToolDefinition, history []contracts.Message) []contracts.ToolDefinition {
@@ -114,6 +121,89 @@ func prependAvailableDeferredToolsMessage(messages []contracts.APIMessage, toolN
114121
return out
115122
}
116123

124+
func stripToolReferenceBlocksFromAPIMessages(messages []contracts.APIMessage) []contracts.APIMessage {
125+
out := make([]contracts.APIMessage, len(messages))
126+
for i, message := range messages {
127+
out[i] = message
128+
if message.Role != "user" {
129+
continue
130+
}
131+
out[i].Content = stripToolReferenceBlocksFromContent(message.Content)
132+
}
133+
return out
134+
}
135+
136+
func stripToolReferenceBlocksFromContent(content []contracts.ContentBlock) []contracts.ContentBlock {
137+
out := make([]contracts.ContentBlock, len(content))
138+
for i, block := range content {
139+
out[i] = block
140+
if block.Type != contracts.ContentToolResult {
141+
continue
142+
}
143+
if stripped, ok := stripToolReferenceItems(block.Content); ok {
144+
out[i].Content = stripped
145+
}
146+
}
147+
return out
148+
}
149+
150+
func stripToolReferenceItems(content any) (any, bool) {
151+
items, ok := toolResultContentItems(content)
152+
if !ok {
153+
return content, false
154+
}
155+
filtered := make([]any, 0, len(items))
156+
removed := false
157+
for _, item := range items {
158+
if toolReferenceItem(item) {
159+
removed = true
160+
continue
161+
}
162+
filtered = append(filtered, item)
163+
}
164+
if !removed {
165+
return content, false
166+
}
167+
if len(filtered) == 0 {
168+
return []contracts.ContentBlock{contracts.NewTextBlock("[Tool references removed - tool search not enabled]")}, true
169+
}
170+
return filtered, true
171+
}
172+
173+
func toolResultContentItems(content any) ([]any, bool) {
174+
switch typed := content.(type) {
175+
case []any:
176+
return typed, true
177+
case []contracts.ToolReference:
178+
out := make([]any, 0, len(typed))
179+
for _, item := range typed {
180+
out = append(out, item)
181+
}
182+
return out, true
183+
case []contracts.ContentBlock:
184+
out := make([]any, 0, len(typed))
185+
for _, item := range typed {
186+
out = append(out, item)
187+
}
188+
return out, true
189+
default:
190+
return nil, false
191+
}
192+
}
193+
194+
func toolReferenceItem(item any) bool {
195+
switch typed := item.(type) {
196+
case contracts.ToolReference:
197+
return typed.Type == "tool_reference"
198+
case map[string]any:
199+
return toolReferenceType(typed)
200+
case contracts.ContentBlock:
201+
return typed.Type == "tool_reference"
202+
default:
203+
return false
204+
}
205+
}
206+
117207
func deferredToolNames(definitions []contracts.ToolDefinition) []string {
118208
var names []string
119209
for _, definition := range definitions {

internal/conversation/run_test.go

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,18 +354,26 @@ func TestRunnerPreservesToolReferenceResultContent(t *testing.T) {
354354
Model: "sonnet",
355355
MaxTokens: 128,
356356
}
357-
if _, err := runner.RunTurn(context.Background(), nil, messages.UserText("find tools")); err != nil {
357+
result, err := runner.RunTurn(context.Background(), nil, messages.UserText("find tools"))
358+
if err != nil {
358359
t.Fatal(err)
359360
}
361+
if len(result.ToolResults) != 1 {
362+
t.Fatalf("tool results = %#v", result.ToolResults)
363+
}
364+
references, ok := result.ToolResults[0].Content.([]contracts.ToolReference)
365+
if !ok || len(references) != 2 || references[0].ToolName != "Read" || references[1].ToolName != "Edit" {
366+
t.Fatalf("runner tool result content = %#v", result.ToolResults[0].Content)
367+
}
360368
if len(client.requests) != 2 {
361369
t.Fatalf("requests = %d, want 2", len(client.requests))
362370
}
363371
last := client.requests[1].Messages[len(client.requests[1].Messages)-1]
364372
if last.Role != "user" || len(last.Content) != 1 || last.Content[0].Type != contracts.ContentToolResult {
365373
t.Fatalf("last api message = %#v", last)
366374
}
367-
references, ok := last.Content[0].Content.([]contracts.ToolReference)
368-
if !ok || len(references) != 2 || references[0].ToolName != "Read" || references[1].ToolName != "Edit" {
375+
placeholder, ok := last.Content[0].Content.([]contracts.ContentBlock)
376+
if !ok || len(placeholder) != 1 || placeholder[0].Text != "[Tool references removed - tool search not enabled]" {
369377
t.Fatalf("tool result content = %#v", last.Content[0].Content)
370378
}
371379
}
@@ -8084,6 +8092,104 @@ func TestBuildRequestWithToolSearchButNoDeferredToolsOmitsToolSearch(t *testing.
80848092
}
80858093
}
80868094

8095+
func TestBuildRequestStripsToolReferencesWhenToolSearchInactive(t *testing.T) {
8096+
registry, err := tool.NewRegistry(tasktools.NewTaskTool())
8097+
if err != nil {
8098+
t.Fatal(err)
8099+
}
8100+
runner := Runner{
8101+
Tools: tool.NewExecutor(registry),
8102+
Model: "sonnet",
8103+
MaxTokens: 100,
8104+
}
8105+
history := []contracts.Message{{
8106+
Type: contracts.MessageUser,
8107+
Content: []contracts.ContentBlock{{
8108+
Type: contracts.ContentToolResult,
8109+
ToolUseID: "toolu_search",
8110+
Content: []contracts.ToolReference{contracts.NewToolReference("Task")},
8111+
}},
8112+
}}
8113+
req, err := runner.BuildRequest(history, "sonnet")
8114+
if err != nil {
8115+
t.Fatal(err)
8116+
}
8117+
if len(req.Messages) != 1 {
8118+
t.Fatalf("messages = %#v", req.Messages)
8119+
}
8120+
block := req.Messages[0].Content[0]
8121+
items, ok := block.Content.([]contracts.ContentBlock)
8122+
if !ok || len(items) != 1 || items[0].Text != "[Tool references removed - tool search not enabled]" {
8123+
t.Fatalf("tool result content = %#v", block.Content)
8124+
}
8125+
if !requestHasTool(req, "Task") || requestTool(req, "Task").DeferLoading {
8126+
t.Fatalf("tools = %#v", req.Tools)
8127+
}
8128+
}
8129+
8130+
func TestBuildRequestStripsOnlyToolReferencesFromMixedContentWhenInactive(t *testing.T) {
8131+
registry, err := tool.NewRegistry(tasktools.NewTaskTool())
8132+
if err != nil {
8133+
t.Fatal(err)
8134+
}
8135+
runner := Runner{
8136+
Tools: tool.NewExecutor(registry),
8137+
Model: "sonnet",
8138+
MaxTokens: 100,
8139+
}
8140+
history := []contracts.Message{{
8141+
Type: contracts.MessageUser,
8142+
Content: []contracts.ContentBlock{{
8143+
Type: contracts.ContentToolResult,
8144+
ToolUseID: "toolu_search",
8145+
Content: []any{
8146+
map[string]any{"type": "text", "text": "kept"},
8147+
map[string]any{"type": "tool_reference", "tool_name": "Task"},
8148+
},
8149+
}},
8150+
}}
8151+
req, err := runner.BuildRequest(history, "sonnet")
8152+
if err != nil {
8153+
t.Fatal(err)
8154+
}
8155+
items, ok := req.Messages[0].Content[0].Content.([]any)
8156+
if !ok || len(items) != 1 {
8157+
t.Fatalf("tool result content = %#v", req.Messages[0].Content[0].Content)
8158+
}
8159+
item, ok := items[0].(map[string]any)
8160+
if !ok || item["type"] != "text" || item["text"] != "kept" {
8161+
t.Fatalf("kept item = %#v", items[0])
8162+
}
8163+
}
8164+
8165+
func TestBuildRequestKeepsToolReferencesWhenToolSearchActive(t *testing.T) {
8166+
registry, err := tool.NewRegistry(tasktools.NewTaskTool(), searchtools.NewToolSearchTool())
8167+
if err != nil {
8168+
t.Fatal(err)
8169+
}
8170+
runner := Runner{
8171+
Tools: tool.NewExecutor(registry),
8172+
Model: "sonnet",
8173+
MaxTokens: 100,
8174+
}
8175+
history := []contracts.Message{{
8176+
Type: contracts.MessageUser,
8177+
Content: []contracts.ContentBlock{{
8178+
Type: contracts.ContentToolResult,
8179+
ToolUseID: "toolu_search",
8180+
Content: []contracts.ToolReference{contracts.NewToolReference("Task")},
8181+
}},
8182+
}}
8183+
req, err := runner.BuildRequest(history, "sonnet")
8184+
if err != nil {
8185+
t.Fatal(err)
8186+
}
8187+
items, ok := req.Messages[1].Content[0].Content.([]contracts.ToolReference)
8188+
if !ok || len(items) != 1 || items[0].ToolName != "Task" {
8189+
t.Fatalf("tool result content = %#v", req.Messages[1].Content[0].Content)
8190+
}
8191+
}
8192+
80878193
func TestBuildRequestLoadsDiscoveredToolReferences(t *testing.T) {
80888194
registry, err := tool.NewRegistry(tasktools.NewTaskTool())
80898195
if err != nil {

0 commit comments

Comments
 (0)