detached 8f1edc1

Honor deferred tools across providers

commit
8f1edc1c52de390fd507e6350c55febbd160b6bf
author
patriceckhart · · 2026-07-21 16:32 UTC
parent
dfc299e
signature
unsigned
7 files · +123 -31
M packages/provider/amazon_bedrock.go +3 -2
@@ -411,11 +411,12 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
411411 }
412412 out.Messages = append(out.Messages, bm)
413413 }
414- if len(req.Tools) > 0 {
414+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
415+ if len(activeTools) > 0 {
415416 tc := struct {
416417 Tools []bedrockToolSpec `json:"tools"`
417418 }{}
418- for _, t := range req.Tools {
419+ for _, t := range activeTools {
419420 var ts bedrockToolSpec
420421 ts.ToolSpec.Name = t.Name
421422 ts.ToolSpec.Description = t.Description
@@ -411,11 +411,12 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
411 }411 }
412 out.Messages = append(out.Messages, bm)412 out.Messages = append(out.Messages, bm)
413 }413 }
414- if len(req.Tools) > 0 {414+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
415+ if len(activeTools) > 0 {
415 tc := struct {416 tc := struct {
416 Tools []bedrockToolSpec `json:"tools"`417 Tools []bedrockToolSpec `json:"tools"`
417 }{}418 }{}
418- for _, t := range req.Tools {419+ for _, t := range activeTools {
419 var ts bedrockToolSpec420 var ts bedrockToolSpec
420 ts.ToolSpec.Name = t.Name421 ts.ToolSpec.Name = t.Name
421 ts.ToolSpec.Description = t.Description422 ts.ToolSpec.Description = t.Description
M packages/provider/anthropic.go +1 -1
@@ -312,7 +312,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
312312 }
313313 }
314314
315- for _, t := range req.Tools {
315+ for _, t := range activeToolDefinitions(req.Tools, req.Messages) {
316316 name := t.Name
317317 if c.oauthTok != "" {
318318 name = toClaudeCodeToolName(name)
@@ -312,7 +312,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
312 }312 }
313 }313 }
314 314
315- for _, t := range req.Tools {315+ for _, t := range activeToolDefinitions(req.Tools, req.Messages) {
316 name := t.Name316 name := t.Name
317 if c.oauthTok != "" {317 if c.oauthTok != "" {
318 name = toClaudeCodeToolName(name)318 name = toClaudeCodeToolName(name)
M packages/provider/deferred_tools_test.go +87 -17
@@ -38,29 +38,99 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
3838 }
3939
4040 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
41- client := NewOpenAI("token", "").(*openaiClient)
4241 tools := []Tool{
4342 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
4443 {Name: "late", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
4544 }
46- wire, err := client.buildRequest(Request{Model: "gpt-5", Tools: tools})
47- if err != nil {
48- t.Fatal(err)
49- }
50- if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "base" {
51- t.Fatalf("initial tools = %+v", wire.Tools)
45+ builders := map[string]func(Request) ([]string, error){
46+ "openai": func(req Request) ([]string, error) {
47+ wire, err := NewOpenAI("token", "").(*openaiClient).buildRequest(req)
48+ if err != nil {
49+ return nil, err
50+ }
51+ names := make([]string, 0, len(wire.Tools))
52+ for _, tool := range wire.Tools {
53+ names = append(names, tool.Function.Name)
54+ }
55+ return names, nil
56+ },
57+ "anthropic": func(req Request) ([]string, error) {
58+ req.Model = "claude-sonnet-4-6"
59+ wire, err := NewAnthropic("token", "").(*anthropicClient).buildRequest(req)
60+ if err != nil {
61+ return nil, err
62+ }
63+ names := make([]string, 0, len(wire.Tools))
64+ for _, tool := range wire.Tools {
65+ names = append(names, tool.Name)
66+ }
67+ return names, nil
68+ },
69+ "bedrock": func(req Request) ([]string, error) {
70+ req.Model = "anthropic.claude-sonnet-4-5-20250929-v1:0"
71+ wire, err := (&bedrockClient{region: "us-east-1"}).buildRequest(req)
72+ if err != nil {
73+ return nil, err
74+ }
75+ if wire.ToolConfig == nil {
76+ return nil, nil
77+ }
78+ names := make([]string, 0, len(wire.ToolConfig.Tools))
79+ for _, tool := range wire.ToolConfig.Tools {
80+ names = append(names, tool.ToolSpec.Name)
81+ }
82+ return names, nil
83+ },
84+ "gemini": func(req Request) ([]string, error) {
85+ req.Model = "gemini-2.5-pro"
86+ wire, _, err := NewGemini("token", "").(*geminiClient).buildRequest(req)
87+ if err != nil {
88+ return nil, err
89+ }
90+ var names []string
91+ for _, group := range wire.Tools {
92+ for _, tool := range group.FunctionDeclarations {
93+ names = append(names, tool.Name)
94+ }
95+ }
96+ return names, nil
97+ },
98+ "openai-codex": func(req Request) ([]string, error) {
99+ req.Model = "gpt-5.5"
100+ wire, err := NewOpenAICodex("token", "account", "").(*codexClient).buildRequest(req)
101+ if err != nil {
102+ return nil, err
103+ }
104+ names := make([]string, 0, len(wire.Tools))
105+ for _, tool := range wire.Tools {
106+ names = append(names, tool.Name)
107+ }
108+ return names, nil
109+ },
52110 }
53111
54- wire, err = client.buildRequest(Request{
55- Model: "gpt-5",
56- Tools: tools,
57- Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},
58- })
59- if err != nil {
60- t.Fatal(err)
61- }
62- if len(wire.Tools) != 2 {
63- t.Fatalf("activated tools = %+v, want both definitions", wire.Tools)
112+ for name, build := range builders {
113+ t.Run(name, func(t *testing.T) {
114+ initial, err := build(Request{Model: "gpt-5", Tools: tools})
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ if len(initial) != 1 || initial[0] != "base" {
119+ t.Fatalf("initial tools = %v, want [base]", initial)
120+ }
121+
122+ activated, err := build(Request{
123+ Model: "gpt-5",
124+ Tools: tools,
125+ Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},
126+ })
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if len(activated) != 2 || activated[0] != "base" || activated[1] != "late" {
131+ t.Fatalf("activated tools = %v, want [base late]", activated)
132+ }
133+ })
64134 }
65135 }
66136
@@ -38,29 +38,99 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
38 }38 }
39 39
40 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {40 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
41- client := NewOpenAI("token", "").(*openaiClient)
42 tools := []Tool{41 tools := []Tool{
43 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},42 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
44 {Name: "late", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},43 {Name: "late", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
45 }44 }
46- wire, err := client.buildRequest(Request{Model: "gpt-5", Tools: tools})45+ builders := map[string]func(Request) ([]string, error){
47- if err != nil {46+ "openai": func(req Request) ([]string, error) {
48- t.Fatal(err)47+ wire, err := NewOpenAI("token", "").(*openaiClient).buildRequest(req)
49- }48+ if err != nil {
50- if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "base" {49+ return nil, err
51- t.Fatalf("initial tools = %+v", wire.Tools)50+ }
51+ names := make([]string, 0, len(wire.Tools))
52+ for _, tool := range wire.Tools {
53+ names = append(names, tool.Function.Name)
54+ }
55+ return names, nil
56+ },
57+ "anthropic": func(req Request) ([]string, error) {
58+ req.Model = "claude-sonnet-4-6"
59+ wire, err := NewAnthropic("token", "").(*anthropicClient).buildRequest(req)
60+ if err != nil {
61+ return nil, err
62+ }
63+ names := make([]string, 0, len(wire.Tools))
64+ for _, tool := range wire.Tools {
65+ names = append(names, tool.Name)
66+ }
67+ return names, nil
68+ },
69+ "bedrock": func(req Request) ([]string, error) {
70+ req.Model = "anthropic.claude-sonnet-4-5-20250929-v1:0"
71+ wire, err := (&bedrockClient{region: "us-east-1"}).buildRequest(req)
72+ if err != nil {
73+ return nil, err
74+ }
75+ if wire.ToolConfig == nil {
76+ return nil, nil
77+ }
78+ names := make([]string, 0, len(wire.ToolConfig.Tools))
79+ for _, tool := range wire.ToolConfig.Tools {
80+ names = append(names, tool.ToolSpec.Name)
81+ }
82+ return names, nil
83+ },
84+ "gemini": func(req Request) ([]string, error) {
85+ req.Model = "gemini-2.5-pro"
86+ wire, _, err := NewGemini("token", "").(*geminiClient).buildRequest(req)
87+ if err != nil {
88+ return nil, err
89+ }
90+ var names []string
91+ for _, group := range wire.Tools {
92+ for _, tool := range group.FunctionDeclarations {
93+ names = append(names, tool.Name)
94+ }
95+ }
96+ return names, nil
97+ },
98+ "openai-codex": func(req Request) ([]string, error) {
99+ req.Model = "gpt-5.5"
100+ wire, err := NewOpenAICodex("token", "account", "").(*codexClient).buildRequest(req)
101+ if err != nil {
102+ return nil, err
103+ }
104+ names := make([]string, 0, len(wire.Tools))
105+ for _, tool := range wire.Tools {
106+ names = append(names, tool.Name)
107+ }
108+ return names, nil
109+ },
52 }110 }
53 111
54- wire, err = client.buildRequest(Request{112+ for name, build := range builders {
55- Model: "gpt-5",113+ t.Run(name, func(t *testing.T) {
56- Tools: tools,114+ initial, err := build(Request{Model: "gpt-5", Tools: tools})
57- Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},115+ if err != nil {
58- })116+ t.Fatal(err)
59- if err != nil {117+ }
60- t.Fatal(err)118+ if len(initial) != 1 || initial[0] != "base" {
61- }119+ t.Fatalf("initial tools = %v, want [base]", initial)
62- if len(wire.Tools) != 2 {120+ }
63- t.Fatalf("activated tools = %+v, want both definitions", wire.Tools)121+
122+ activated, err := build(Request{
123+ Model: "gpt-5",
124+ Tools: tools,
125+ Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},
126+ })
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if len(activated) != 2 || activated[0] != "base" || activated[1] != "late" {
131+ t.Fatalf("activated tools = %v, want [base late]", activated)
132+ }
133+ })
64 }134 }
65 }135 }
66 136
M packages/provider/gemini.go +4 -3
@@ -151,13 +151,14 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
151151 }
152152
153153 functionsEnabled := geminiSupportsFunctionCalling(m.ID)
154+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
154155
155156 // Convert tool defs. Gemini image-generation models reject function
156157 // declarations with "Function calling is not enabled for this model";
157158 // for those models, send a direct multimodal prompt instead.
158- if functionsEnabled && len(req.Tools) > 0 {
159- decls := make([]gemFunctionDecl, 0, len(req.Tools))
160- for _, t := range req.Tools {
159+ if functionsEnabled && len(activeTools) > 0 {
160+ decls := make([]gemFunctionDecl, 0, len(activeTools))
161+ for _, t := range activeTools {
161162 schema := sanitizeGeminiToolSchema(t.Schema)
162163 decls = append(decls, gemFunctionDecl{
163164 Name: t.Name,
@@ -151,13 +151,14 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
151 }151 }
152 152
153 functionsEnabled := geminiSupportsFunctionCalling(m.ID)153 functionsEnabled := geminiSupportsFunctionCalling(m.ID)
154+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
154 155
155 // Convert tool defs. Gemini image-generation models reject function156 // Convert tool defs. Gemini image-generation models reject function
156 // declarations with "Function calling is not enabled for this model";157 // declarations with "Function calling is not enabled for this model";
157 // for those models, send a direct multimodal prompt instead.158 // for those models, send a direct multimodal prompt instead.
158- if functionsEnabled && len(req.Tools) > 0 {159+ if functionsEnabled && len(activeTools) > 0 {
159- decls := make([]gemFunctionDecl, 0, len(req.Tools))160+ decls := make([]gemFunctionDecl, 0, len(activeTools))
160- for _, t := range req.Tools {161+ for _, t := range activeTools {
161 schema := sanitizeGeminiToolSchema(t.Schema)162 schema := sanitizeGeminiToolSchema(t.Schema)
162 decls = append(decls, gemFunctionDecl{163 decls = append(decls, gemFunctionDecl{
163 Name: t.Name,164 Name: t.Name,
M packages/provider/openai.go +1 -6
@@ -270,15 +270,10 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
270270
271271 deferredMode := isKimiDeferredModel(req.Model)
272272 toolByName := make(map[string]Tool, len(req.Tools))
273- activatedTools := make(map[string]bool)
274273 for _, t := range req.Tools {
275274 toolByName[t.Name] = t
276275 }
277- for _, msg := range req.Messages {
278- for _, name := range msg.AddedToolNames {
279- activatedTools[name] = true
280- }
281- }
276+ activatedTools := activatedToolNames(req.Messages)
282277
283278 // DeepSeek's chat-completions API rejects the multimodal content
284279 // schema (parts arrays containing image_url). Force every user/tool
@@ -270,15 +270,10 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
270 270
271 deferredMode := isKimiDeferredModel(req.Model)271 deferredMode := isKimiDeferredModel(req.Model)
272 toolByName := make(map[string]Tool, len(req.Tools))272 toolByName := make(map[string]Tool, len(req.Tools))
273- activatedTools := make(map[string]bool)
274 for _, t := range req.Tools {273 for _, t := range req.Tools {
275 toolByName[t.Name] = t274 toolByName[t.Name] = t
276 }275 }
277- for _, msg := range req.Messages {276+ activatedTools := activatedToolNames(req.Messages)
278- for _, name := range msg.AddedToolNames {
279- activatedTools[name] = true
280- }
281- }
282 277
283 // DeepSeek's chat-completions API rejects the multimodal content278 // DeepSeek's chat-completions API rejects the multimodal content
284 // schema (parts arrays containing image_url). Force every user/tool279 // schema (parts arrays containing image_url). Force every user/tool
M packages/provider/openai_codex.go +3 -2
@@ -191,9 +191,10 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
191191 body.Reasoning = &codexReasoningConfig{Effort: effort}
192192 }
193193 }
194- if len(req.Tools) > 0 {
194+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
195+ if len(activeTools) > 0 {
195196 body.ToolChoice = "auto"
196- for _, t := range req.Tools {
197+ for _, t := range activeTools {
197198 params := t.Schema
198199 if len(params) == 0 {
199200 params = json.RawMessage(`{"type":"object","properties":{}}`)
@@ -191,9 +191,10 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
191 body.Reasoning = &codexReasoningConfig{Effort: effort}191 body.Reasoning = &codexReasoningConfig{Effort: effort}
192 }192 }
193 }193 }
194- if len(req.Tools) > 0 {194+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
195+ if len(activeTools) > 0 {
195 body.ToolChoice = "auto"196 body.ToolChoice = "auto"
196- for _, t := range req.Tools {197+ for _, t := range activeTools {
197 params := t.Schema198 params := t.Schema
198 if len(params) == 0 {199 if len(params) == 0 {
199 params = json.RawMessage(`{"type":"object","properties":{}}`)200 params = json.RawMessage(`{"type":"object","properties":{}}`)
M packages/provider/provider.go +24 -0
@@ -130,6 +130,30 @@ type Tool struct {
130130 Deferred bool `json:"deferred,omitempty"`
131131 }
132132
133+// activeToolDefinitions returns eager tools plus deferred tools activated by
134+// prior tool results. Providers with a native position-sensitive deferred-tool
135+// format can use the activation markers directly instead.
136+func activeToolDefinitions(tools []Tool, messages []Message) []Tool {
137+ activated := activatedToolNames(messages)
138+ active := make([]Tool, 0, len(tools))
139+ for _, tool := range tools {
140+ if !tool.Deferred || activated[tool.Name] {
141+ active = append(active, tool)
142+ }
143+ }
144+ return active
145+}
146+
147+func activatedToolNames(messages []Message) map[string]bool {
148+ activated := make(map[string]bool)
149+ for _, message := range messages {
150+ for _, name := range message.AddedToolNames {
151+ activated[name] = true
152+ }
153+ }
154+ return activated
155+}
156+
133157 // Usage aggregates token counts and cost for a turn.
134158 type Usage struct {
135159 InputTokens int `json:"input_tokens"`
@@ -130,6 +130,30 @@ type Tool struct {
130 Deferred bool `json:"deferred,omitempty"`130 Deferred bool `json:"deferred,omitempty"`
131 }131 }
132 132
133+// activeToolDefinitions returns eager tools plus deferred tools activated by
134+// prior tool results. Providers with a native position-sensitive deferred-tool
135+// format can use the activation markers directly instead.
136+func activeToolDefinitions(tools []Tool, messages []Message) []Tool {
137+ activated := activatedToolNames(messages)
138+ active := make([]Tool, 0, len(tools))
139+ for _, tool := range tools {
140+ if !tool.Deferred || activated[tool.Name] {
141+ active = append(active, tool)
142+ }
143+ }
144+ return active
145+}
146+
147+func activatedToolNames(messages []Message) map[string]bool {
148+ activated := make(map[string]bool)
149+ for _, message := range messages {
150+ for _, name := range message.AddedToolNames {
151+ activated[name] = true
152+ }
153+ }
154+ return activated
155+}
156+
133 // Usage aggregates token counts and cost for a turn.157 // Usage aggregates token counts and cost for a turn.
134 type Usage struct {158 type Usage struct {
135 InputTokens int `json:"input_tokens"`159 InputTokens int `json:"input_tokens"`

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close