detached 8c2eb47

Gate the Kimi deferred-tools handshake to Moonshot providers

The deferred-tools path delivers a mid-conversation tool definition on a
system message carrying a `tools` array and no content. That is a Moonshot
API extension, but `deferredMode` was keyed on the model id alone, so every
OpenAI-compatible provider serving a model named `kimi-k3` received it.

Gondola, whose built-in provider defaults to `kimi-k3`, answers
`messages.N: Invalid input`: its upstream requires content on a system
message. The turn cannot recover either, because `AddedToolNames` is
persisted on the message and survives history rebuilds, compaction and
session resume, so the injection is re-emitted on every later request in
that session.

Adding content to the injected message is not a fix. The upstream accepts a
message-level `tools` key and ignores it, so the loaded tool would silently
never become callable. Gating on the provider instead routes everyone else
through the standard path, where an activated deferred tool joins the
top-level tools array.

Verified against Gondola with an extension registering a deferred tool:
before, the turn after activation fails with the 400 above; after, the
agent loads the tool, calls it, and completes the loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
commit
8c2eb47593f1fd4eadff61935bba5c08ec5be21f
author
Codonyat · · 2026-08-14 20:36 UTC
parent
e900970
signature
unsigned
2 files · +55 -1
M packages/provider/deferred_tools_test.go +39 -0
@@ -37,6 +37,45 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
3737 }
3838 }
3939
40+// The deferred-tools handshake is a Moonshot API extension. Another provider
41+// serving the same model id must get the standard wire instead: no tools-bearing
42+// system message (it carries no content, which strict schemas reject), and the
43+// activated tool in the top-level definitions where every provider reads it.
44+func TestKimiK3DeferredToolsSkippedForNonMoonshotProvider(t *testing.T) {
45+ client := NewGondola("token", "").(*openaiClient)
46+ tools := []Tool{
47+ {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
48+ {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
49+ }
50+ wire, err := client.buildRequest(Request{
51+ Model: "kimi-k3",
52+ Tools: tools,
53+ Messages: []Message{
54+ {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
55+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
56+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
57+ },
58+ })
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ for _, message := range wire.Messages {
63+ if len(message.Tools) > 0 {
64+ t.Fatalf("message %q carries tools; deferred injection must not reach a non-Moonshot provider", message.Role)
65+ }
66+ if message.Content == nil && len(message.ToolCalls) == 0 {
67+ t.Fatalf("message %q has neither content nor tool calls", message.Role)
68+ }
69+ }
70+ var names []string
71+ for _, tool := range wire.Tools {
72+ names = append(names, tool.Function.Name)
73+ }
74+ if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" {
75+ t.Fatalf("top-level tools = %v, want [search_tools lookup_weather]", names)
76+ }
77+}
78+
4079 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
4180 tools := []Tool{
4281 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
@@ -37,6 +37,45 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
37 }37 }
38 }38 }
39 39
40+// The deferred-tools handshake is a Moonshot API extension. Another provider
41+// serving the same model id must get the standard wire instead: no tools-bearing
42+// system message (it carries no content, which strict schemas reject), and the
43+// activated tool in the top-level definitions where every provider reads it.
44+func TestKimiK3DeferredToolsSkippedForNonMoonshotProvider(t *testing.T) {
45+ client := NewGondola("token", "").(*openaiClient)
46+ tools := []Tool{
47+ {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
48+ {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
49+ }
50+ wire, err := client.buildRequest(Request{
51+ Model: "kimi-k3",
52+ Tools: tools,
53+ Messages: []Message{
54+ {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
55+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
56+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
57+ },
58+ })
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ for _, message := range wire.Messages {
63+ if len(message.Tools) > 0 {
64+ t.Fatalf("message %q carries tools; deferred injection must not reach a non-Moonshot provider", message.Role)
65+ }
66+ if message.Content == nil && len(message.ToolCalls) == 0 {
67+ t.Fatalf("message %q has neither content nor tool calls", message.Role)
68+ }
69+ }
70+ var names []string
71+ for _, tool := range wire.Tools {
72+ names = append(names, tool.Function.Name)
73+ }
74+ if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" {
75+ t.Fatalf("top-level tools = %v, want [search_tools lookup_weather]", names)
76+ }
77+}
78+
40 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {79 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
41 tools := []Tool{80 tools := []Tool{
42 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},81 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
M packages/provider/openai.go +16 -1
@@ -276,7 +276,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
276276 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
277277 }
278278
279- deferredMode := isKimiDeferredModel(req.Model)
279+ deferredMode := supportsDeferredTools(c.Name()) && isKimiDeferredModel(req.Model)
280280 toolByName := make(map[string]Tool, len(req.Tools))
281281 for _, t := range req.Tools {
282282 toolByName[t.Name] = t
@@ -392,6 +392,21 @@ func isKimiDeferredModel(model string) bool {
392392 return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")
393393 }
394394
395+// supportsDeferredTools reports whether a provider speaks Moonshot's
396+// deferred-tools handshake, where a tool that becomes available mid-conversation
397+// is delivered on a system message carrying a `tools` array.
398+//
399+// That message is a Moonshot API extension, not OpenAI wire format: it has no
400+// content, which a strict chat-completions schema rejects outright. Gondola,
401+// which serves kimi-k3 on top of Venice, answers "messages.N: Invalid input"
402+// and the conversation cannot recover, because the injection is rebuilt from
403+// message history on every later turn. Providers outside this list take the
404+// standard path instead, where an activated deferred tool joins the top-level
405+// tools array.
406+func supportsDeferredTools(provider string) bool {
407+ return provider == "moonshotai" || provider == "moonshotai-cn"
408+}
409+
395410 func makeOAITool(t Tool) oaiTool {
396411 var tool oaiTool
397412 tool.Type = "function"
@@ -276,7 +276,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
276 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})276 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
277 }277 }
278 278
279- deferredMode := isKimiDeferredModel(req.Model)279+ deferredMode := supportsDeferredTools(c.Name()) && isKimiDeferredModel(req.Model)
280 toolByName := make(map[string]Tool, len(req.Tools))280 toolByName := make(map[string]Tool, len(req.Tools))
281 for _, t := range req.Tools {281 for _, t := range req.Tools {
282 toolByName[t.Name] = t282 toolByName[t.Name] = t
@@ -392,6 +392,21 @@ func isKimiDeferredModel(model string) bool {
392 return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")392 return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")
393 }393 }
394 394
395+// supportsDeferredTools reports whether a provider speaks Moonshot's
396+// deferred-tools handshake, where a tool that becomes available mid-conversation
397+// is delivered on a system message carrying a `tools` array.
398+//
399+// That message is a Moonshot API extension, not OpenAI wire format: it has no
400+// content, which a strict chat-completions schema rejects outright. Gondola,
401+// which serves kimi-k3 on top of Venice, answers "messages.N: Invalid input"
402+// and the conversation cannot recover, because the injection is rebuilt from
403+// message history on every later turn. Providers outside this list take the
404+// standard path instead, where an activated deferred tool joins the top-level
405+// tools array.
406+func supportsDeferredTools(provider string) bool {
407+ return provider == "moonshotai" || provider == "moonshotai-cn"
408+}
409+
395 func makeOAITool(t Tool) oaiTool {410 func makeOAITool(t Tool) oaiTool {
396 var tool oaiTool411 var tool oaiTool
397 tool.Type = "function"412 tool.Type = "function"

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