Skip to content

[Bug]: [ai-sidecar] Tool results are double-wrapped in raw_output, so the AG-UI gateway never flattens them #9289

Description

@aarushitandon0

What happened?

The Gemini sidecar wraps each MCP tool result in an extra content layer before putting it on the ACP wire. The Jaeger AI gateway's flattener expects the result without this extra layer, so its type assertion fails and it falls back to JSON-encoding the entire nested value. As a result, TOOL_CALL_RESULT.content ends up containing a serialized protocol envelope instead of the tool's actual text.

Sidecar wraps the result — [sidecar.py:309](

raw_output={"content": tool_output},
):

raw_output={"content": tool_output},

tool_output comes from [sidecar.py:299](

tool_output = await self._mcp.call_tool(tool_name, args)
), which calls [mcp_bridge.py:104](https://github.com/jaegertracing/jaeger/blob/2487d690445e0d51b04075f04ea70abe078b4a35/scripts/ai-sidecar/gemini/mcp_bridge.py#L104):

return _to_jsonable(result)

_to_jsonable ([sidecar_helpers.py:86-91](

def _to_jsonable(value: Any) -> Any:
if hasattr(value, "model_dump"):
return value.model_dump()
if hasattr(value, "dict"):
return value.dict()
return value
)) converts Pydantic models to mappings and leaves non-model values unchanged. This means the MCP CallToolResult reaches the gateway as a dictionary rather than the list of content blocks the gateway expects. The captured frame confirms that this is the actual runtime shape.

Gateway expects a flat result — [translation.go:268-291](

func flattenToolResultContent(raw any) string {
if envelope, ok := raw.(map[string]any); ok {
if blocks, ok := envelope["content"].([]any); ok {
texts := make([]string, 0, len(blocks))
for _, block := range blocks {
blockMap, ok := block.(map[string]any)
if !ok {
continue
}
if text, ok := blockMap["text"].(string); ok {
texts = append(texts, text)
}
}
if len(texts) > 0 {
return strings.Join(texts, "\n")
}
}
}
payload, err := json.Marshal(raw)
if err != nil {
return fmt.Sprintf("%v", raw)
}
return string(payload)
}
):

if envelope, ok := raw.(map[string]any); ok {
    if blocks, ok := envelope["content"].([]any); ok {   // receives map[string]any

The gateway expects envelope["content"] to be a []any, but it actually receives a map[string]any because the sidecar has already wrapped the MCP result.

The comment above this code ([translation.go:259-267](

// flattenToolResultContent reduces the sidecar's tool output to the single
// string AG-UI's TOOL_CALL_RESULT.content field expects. The sidecar
// forwards MCP CallToolResult envelopes verbatim — {content:[{type:"text",
// text:"..."}, ...], structuredContent:{...}} — so each text block is
// collected and the result is joined with "\n" to keep block boundaries
// readable (concatenating with no delimiter would mash distinct paragraphs
// like "Found 3 services" + "Top latency: 1.2s" into one run-on string).
// Anything else is JSON-encoded so the frontend always receives a
// deterministic string instead of a nested object.
)) says that the sidecar forwards MCP CallToolResult envelopes verbatim. In practice, it does not; it adds an extra wrapper around the result.

When the type assertion fails, the gateway falls back to json.Marshal(raw) at [translation.go:286](

). The serialized value is then emitted as the tool result by [streaming_client.go:270-276](
if u.ToolCallUpdate.RawOutput != nil {
c.emit(aguievents.NewToolCallResultEvent(
toolResultMessageID(u.ToolCallUpdate.ToolCallId),
string(u.ToolCallUpdate.ToolCallId),
flattenToolResultContent(u.ToolCallUpdate.RawOutput),
))
}
).

Steps to reproduce

Observed TOOL_CALL_RESULT frame from a live get_services call:

{
  "type": "TOOL_CALL_RESULT",
  "timestamp": 1785949730603,
  "messageId": "tool-msg-W0IWpkE5",
  "toolCallId": "W0IWpkE5",
  "content": "{\"content\":{\"content\":[{\"text\":\"{\\\"services\\\":[\\\"jaeger\\\",\\\"jaeger-gemini-sidecar\\\"],\\\"total_count\\\":2,\\\"truncated\\\":false}\",\"type\":\"text\"}],\"isError\":false,\"structuredContent\":{\"services\":[\"jaeger\",\"jaeger-gemini-sidecar\"],\"total_count\":2,\"truncated\":false}}}",
  "role": "tool"
}

The content field is the whole nested envelope, JSON-marshalled. A successful
flatten would have produced the text block alone:

{"services":["jaeger","jaeger-gemini-sidecar"],"total_count":2,"truncated":false}

Two details confirm this came from the json.Marshal(raw) fallback rather than the
text-extraction branch: the value under the outer content is an object, not an
array (so the []any assertion could not have succeeded), and the keys are
alphabetically ordered — content, isError, structuredContent, and text
before type — which is Go's encoding/json map ordering, not MCP's emission
order.

The same shape in isolation, fed to the flattener, reproduces that content field
byte-for-byte:

callToolResult := map[string]any{
    "content":           []any{map[string]any{"type": "text", "text": toolText}},
    "isError":           false,
    "structuredContent": map[string]any{"services": []any{"jaeger", "jaeger-gemini-sidecar"}, "total_count": 2, "truncated": false},
}
got := flattenToolResultContent(map[string]any{"content": callToolResult})

Expected behavior

TOOL_CALL_RESULT.content should carry the tool's text — the newline-joined text
blocks the flattener is written to produce.

Relevant log output

Screenshot

No response

Additional context

Impact

The gateway emits TOOL_CALL_RESULT.content as a nested JSON string where the code
intends joined text, for every MCP tool call in the Gemini sidecar chat path. The
multi-block newline-joining that flattenToolResultContent exists to provide is
unreachable in production — only its fallback branch ever runs. If the frontend
tool-result panel displays content directly, that serialized envelope is what
users see.

Suggested fix, and the design question it raises

A — fix the sidecar. Send raw_output=tool_output unwrapped at sidecar.py:309,
matching what the gateway, its doc comment, and its test already assume. This
breaks test_sidecar_workflow.py:358,
which currently encodes the wrapped shape.

B — fix the gateway. Have flattenToolResultContent unwrap one extra layer.
No sidecar change, no test churn — but it bakes one sidecar's quirk into a
protocol-generic component shared by every ACP sidecar (cf. #9273, #8529).

Whoever picks this up has to decide which side is canonical for ACP raw_output.
I lean toward A, since the gateway is the shared component and the sidecar is
the one deviating from what it documents — but that determines whether the Python
test is corrected or is the spec, so it's a maintainer call.

Jaeger backend version

main @ 2487d69

SDK

No response

Pipeline

No response

Stogage backend

No response

Operating system

No response

Deployment model

No response

Deployment configs

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions