Skip to content

Commit d060de4

Browse files
authored
Merge pull request #2362 from dgageot/board/fix-docker-agent-issue-2354-231c9e1e
fix: handle missing type in schema and orphaned function calls in Responses API
2 parents 29bffa4 + 5bccef7 commit d060de4

5 files changed

Lines changed: 174 additions & 2 deletions

File tree

pkg/model/provider/openai/client.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,30 @@ func convertMessagesToResponseInput(messages []chat.Message) []responses.Respons
663663
}
664664
}
665665
}
666+
// Safety net: ensure every function_call has a matching function_call_output.
667+
// The Responses API rejects requests with orphaned function calls.
668+
// This can happen if tool execution was interrupted (e.g. user cancellation).
669+
pendingCalls := make(map[string]bool)
670+
for _, item := range input {
671+
if item.OfFunctionCall != nil {
672+
pendingCalls[item.OfFunctionCall.CallID] = true
673+
}
674+
if item.OfFunctionCallOutput != nil {
675+
delete(pendingCalls, item.OfFunctionCallOutput.CallID)
676+
}
677+
}
678+
for callID := range pendingCalls {
679+
slog.Warn("Injecting placeholder output for orphaned function call", "call_id", callID)
680+
input = append(input, responses.ResponseInputItemUnionParam{
681+
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
682+
CallID: callID,
683+
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{
684+
OfString: param.NewOpt("(no output — tool call was not executed)"),
685+
},
686+
},
687+
})
688+
}
689+
666690
return input
667691
}
668692

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package openai
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/docker/docker-agent/pkg/chat"
10+
"github.com/docker/docker-agent/pkg/tools"
11+
)
12+
13+
func TestConvertMessagesToResponseInput_OrphanedFunctionCall(t *testing.T) {
14+
// Simulate a conversation where an assistant made 2 tool calls but only
15+
// one has a result (the other was cancelled/interrupted).
16+
messages := []chat.Message{
17+
{Role: chat.MessageRoleUser, Content: "hello"},
18+
{
19+
Role: chat.MessageRoleAssistant,
20+
ToolCalls: []tools.ToolCall{
21+
{ID: "call_1", Type: "function", Function: tools.FunctionCall{Name: "tool_a", Arguments: "{}"}},
22+
{ID: "call_2", Type: "function", Function: tools.FunctionCall{Name: "tool_b", Arguments: "{}"}},
23+
},
24+
},
25+
{Role: chat.MessageRoleTool, Content: "result a", ToolCallID: "call_1"},
26+
// call_2 has no result — orphaned
27+
}
28+
29+
input := convertMessagesToResponseInput(messages)
30+
31+
// Count function calls and outputs
32+
var callIDs, outputIDs []string
33+
for _, item := range input {
34+
if item.OfFunctionCall != nil {
35+
callIDs = append(callIDs, item.OfFunctionCall.CallID)
36+
}
37+
if item.OfFunctionCallOutput != nil {
38+
outputIDs = append(outputIDs, item.OfFunctionCallOutput.CallID)
39+
}
40+
}
41+
42+
require.Len(t, callIDs, 2)
43+
require.Len(t, outputIDs, 2, "orphaned function call should get a placeholder output")
44+
45+
assert.Contains(t, outputIDs, "call_1")
46+
assert.Contains(t, outputIDs, "call_2")
47+
}
48+
49+
func TestConvertMessagesToResponseInput_NoOrphans(t *testing.T) {
50+
// All tool calls have matching results — no placeholder needed.
51+
messages := []chat.Message{
52+
{Role: chat.MessageRoleUser, Content: "hello"},
53+
{
54+
Role: chat.MessageRoleAssistant,
55+
ToolCalls: []tools.ToolCall{
56+
{ID: "call_1", Type: "function", Function: tools.FunctionCall{Name: "tool_a", Arguments: "{}"}},
57+
},
58+
},
59+
{Role: chat.MessageRoleTool, Content: "result a", ToolCallID: "call_1"},
60+
}
61+
62+
input := convertMessagesToResponseInput(messages)
63+
64+
var outputCount int
65+
for _, item := range input {
66+
if item.OfFunctionCallOutput != nil {
67+
outputCount++
68+
}
69+
}
70+
assert.Equal(t, 1, outputCount, "should not inject extra outputs when all calls have results")
71+
}

pkg/model/provider/openai/schema.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func ConvertParametersToSchema(params any) (shared.FunctionParameters, error) {
1616
return nil, err
1717
}
1818

19-
return fixSchemaArrayItems(removeFormatFields(makeAllRequired(p))), nil
19+
return fixSchemaArrayItems(removeFormatFields(ensureTypeFields(makeAllRequired(p)))), nil
2020
}
2121

2222
// walkSchema calls fn on the given schema node, then recursively walks into
@@ -123,6 +123,23 @@ func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters
123123
return schema
124124
}
125125

126+
// ensureTypeFields ensures every schema node that is a map has a "type" key.
127+
// OpenAI Responses API requires all schema nodes to have an explicit type.
128+
// Nodes with "properties" default to "object"; other nodes default to "object" as well.
129+
func ensureTypeFields(schema shared.FunctionParameters) shared.FunctionParameters {
130+
if schema == nil {
131+
return nil
132+
}
133+
134+
walkSchema(schema, func(node map[string]any) {
135+
if _, hasType := node["type"]; !hasType {
136+
node["type"] = "object"
137+
}
138+
})
139+
140+
return schema
141+
}
142+
126143
// removeFormatFields removes the "format" field from all nodes in the schema.
127144
// OpenAI does not support the JSON Schema "format" keyword (e.g. "uri", "email", "date").
128145
func removeFormatFields(schema shared.FunctionParameters) shared.FunctionParameters {

pkg/model/provider/openai/schema_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,58 @@ func TestMakeAllRequired_TypeArrayWithObject(t *testing.T) {
372372
assert.Equal(t, []string{"number", "null"}, age["type"])
373373
}
374374

375+
func TestEnsureTypeFields_AdditionalPropertiesMissingType(t *testing.T) {
376+
// Reproduces the Notion MCP notion-search tool schema where
377+
// filters.additionalProperties is an object schema without a "type" key.
378+
// OpenAI Responses API rejects schemas missing "type".
379+
schema := shared.FunctionParameters{
380+
"type": "object",
381+
"properties": map[string]any{
382+
"filters": map[string]any{
383+
"type": "object",
384+
"additionalProperties": map[string]any{
385+
// No "type" key here — this is the bug
386+
"properties": map[string]any{
387+
"property": map[string]any{"type": "string"},
388+
},
389+
},
390+
},
391+
},
392+
"required": []any{"filters"},
393+
}
394+
395+
updated := ensureTypeFields(schema)
396+
397+
filters := updated["properties"].(map[string]any)["filters"].(map[string]any)
398+
additionalProps := filters["additionalProperties"].(map[string]any)
399+
assert.Equal(t, "object", additionalProps["type"], "additionalProperties should have type added")
400+
}
401+
402+
func TestConvertParametersToSchema_AdditionalPropertiesMissingType(t *testing.T) {
403+
// End-to-end test: the full pipeline should produce a valid schema
404+
schema := map[string]any{
405+
"type": "object",
406+
"properties": map[string]any{
407+
"filters": map[string]any{
408+
"type": "object",
409+
"additionalProperties": map[string]any{
410+
"properties": map[string]any{
411+
"property": map[string]any{"type": "string"},
412+
},
413+
},
414+
},
415+
},
416+
"required": []any{"filters"},
417+
}
418+
419+
result, err := ConvertParametersToSchema(schema)
420+
require.NoError(t, err)
421+
422+
filters := result["properties"].(map[string]any)["filters"].(map[string]any)
423+
additionalProps := filters["additionalProperties"].(map[string]any)
424+
assert.Equal(t, "object", additionalProps["type"])
425+
}
426+
375427
func TestFixSchemaArrayItems(t *testing.T) {
376428
schema := `{
377429
"properties": {

pkg/runtime/tool_dispatch.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Sessi
3535
agentToolMap[t.Name] = t
3636
}
3737

38-
for _, toolCall := range calls {
38+
for i, toolCall := range calls {
3939
callCtx, callSpan := r.startSpan(ctx, "runtime.tool.call", trace.WithAttributes(
4040
attribute.String("tool.name", toolCall.Function.Name),
4141
attribute.String("tool.type", string(toolCall.Type)),
@@ -74,6 +74,14 @@ func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Sessi
7474
if canceled {
7575
callSpan.SetStatus(codes.Ok, "tool call canceled by user")
7676
callSpan.End()
77+
78+
// Add error results for remaining unprocessed tool calls so the
79+
// conversation history doesn't contain orphaned function calls
80+
// without matching outputs (which the Responses API rejects).
81+
for _, remaining := range calls[i+1:] {
82+
remainingTool := agentToolMap[remaining.Function.Name]
83+
r.addToolErrorResponse(ctx, sess, remaining, remainingTool, events, a, "The tool call was canceled because a previous tool call in the same batch was canceled by the user.")
84+
}
7785
return
7886
}
7987

0 commit comments

Comments
 (0)