Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions llm/transformer/openai/responses/outbound_convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,15 +310,42 @@ func convertToolMessageWithType(msg llm.Message, itemType string) Item {
output.Text = msg.Content.Content
} else if len(msg.Content.MultipleContent) > 0 {
for _, p := range msg.Content.MultipleContent {
if p.Type == "text" && p.Text != nil {
output.Items = append(output.Items, Item{
Type: "input_text",
Text: p.Text,
})
switch p.Type {
case "text":
if p.Text != nil {
output.Items = append(output.Items, Item{
Type: "input_text",
Text: p.Text,
})
}
case "image_url":
// Tool results can carry images (Codex's view_image, MCP screenshot
// tools, ...). Skipping them here leaves output empty, which the
// fallback below turns into "" — the model then sees a successful
// but blank tool result and has no way to tell that it lost data.
if p.ImageURL != nil {
output.Items = append(output.Items, Item{
Type: "input_image",
ImageURL: &p.ImageURL.URL,
Detail: p.ImageURL.Detail,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
}

// Content was present but nothing survived the conversion (audio/video/document
// parts, which a tool result in this API cannot express). Name what was dropped
// rather than falling through to "": a blank-but-successful tool result is
// indistinguishable from a genuinely empty one, so the model reads lost data as
// "the tool returned nothing" and answers anyway.
if output.Text == nil && len(output.Items) == 0 && len(msg.Content.MultipleContent) > 0 {
dropped := lo.Uniq(lo.Map(msg.Content.MultipleContent, func(p llm.MessageContentPart, _ int) string {
return p.Type
}))
output.Text = lo.ToPtr("[axonhub] tool output omitted: unsupported content types: " + strings.Join(dropped, ", "))
}

// Some times the tool result is empty, so we need to add an empty string.
if output.Text == nil && len(output.Items) == 0 {
output.Text = lo.ToPtr("")
Expand Down
127 changes: 125 additions & 2 deletions llm/transformer/openai/responses/outbound_convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func TestConvertToolMessage(t *testing.T) {
},
},
{
name: "tool message with multiple content - mixed types (only text extracted)",
name: "tool message with multiple content - text and image preserved in order",
msg: llm.Message{
Role: "tool",
ToolCallID: lo.ToPtr("call_789"),
Expand Down Expand Up @@ -139,6 +139,10 @@ func TestConvertToolMessage(t *testing.T) {
Type: "input_text",
Text: lo.ToPtr("Text result"),
},
{
Type: "input_image",
ImageURL: lo.ToPtr("https://example.com/image.jpg"),
},
{
Type: "input_text",
Text: lo.ToPtr("More text"),
Expand Down Expand Up @@ -176,7 +180,7 @@ func TestConvertToolMessage(t *testing.T) {
},
},
{
name: "tool message with multiple content but no text parts",
name: "tool message with image and unsupported parts keeps the image",
msg: llm.Message{
Role: "tool",
ToolCallID: lo.ToPtr("call_no_text"),
Expand All @@ -201,6 +205,125 @@ func TestConvertToolMessage(t *testing.T) {
expected: Item{
Type: "function_call_output",
CallID: "call_no_text",
Output: &Input{
Items: []Item{
{
Type: "input_image",
ImageURL: lo.ToPtr("https://example.com/image.jpg"),
},
},
},
},
},
{
// Shape produced by Codex's view_image tool: the result is a single
// base64 image with no text at all. Dropping it made the model see an
// empty-but-successful tool result and silently hallucinate instead.
name: "image-only tool result is preserved as input_image",
msg: llm.Message{
Role: "tool",
ToolCallID: lo.ToPtr("call_view_image"),
Content: llm.MessageContent{
MultipleContent: []llm.MessageContentPart{
{
Type: "image_url",
ImageURL: &llm.ImageURL{
URL: "data:image/png;base64,iVBORw0KGgo=",
Detail: lo.ToPtr("high"),
},
},
},
},
},
expected: Item{
Type: "function_call_output",
CallID: "call_view_image",
Output: &Input{
Items: []Item{
{
Type: "input_image",
ImageURL: lo.ToPtr("data:image/png;base64,iVBORw0KGgo="),
Detail: lo.ToPtr("high"),
},
},
},
},
},
{
name: "tool result with text and image keeps both in order",
msg: llm.Message{
Role: "tool",
ToolCallID: lo.ToPtr("call_mixed"),
Content: llm.MessageContent{
MultipleContent: []llm.MessageContentPart{
{
Type: "text",
Text: lo.ToPtr("screenshot attached"),
},
{
Type: "image_url",
ImageURL: &llm.ImageURL{
URL: "https://example.com/shot.png",
},
},
},
},
},
expected: Item{
Type: "function_call_output",
CallID: "call_mixed",
Output: &Input{
Items: []Item{
{
Type: "input_text",
Text: lo.ToPtr("screenshot attached"),
},
{
Type: "input_image",
ImageURL: lo.ToPtr("https://example.com/shot.png"),
},
},
},
},
},
{
// A tool result whose parts cannot be expressed here must not look like
// an empty-but-successful result, otherwise the model cannot tell that
// data was lost.
name: "tool message with only unsupported parts reports what was dropped",
msg: llm.Message{
Role: "tool",
ToolCallID: lo.ToPtr("call_audio_only"),
Content: llm.MessageContent{
MultipleContent: []llm.MessageContentPart{
{
Type: "input_audio",
InputAudio: &llm.InputAudio{
Data: "audio-data",
Format: "wav",
},
},
},
},
},
expected: Item{
Type: "function_call_output",
CallID: "call_audio_only",
Output: &Input{
Text: lo.ToPtr("[axonhub] tool output omitted: unsupported content types: input_audio"),
},
},
},
{
name: "tool message with no content at all still uses the empty string",
msg: llm.Message{
Role: "tool",
ToolCallID: lo.ToPtr("call_truly_empty"),
Content: llm.MessageContent{MultipleContent: []llm.MessageContentPart{}},
},
expected: Item{
Type: "function_call_output",
CallID: "call_truly_empty",
Output: &Input{
Text: lo.ToPtr(""),
},
Expand Down
Loading