Skip to content

fix(agent): fall back for unrecognized CallToolResult content types - #9691

Open
Trainingcqy wants to merge 1 commit into
AstrBotDevs:masterfrom
Trainingcqy:fix/mcp-tool-result-unsupported-content
Open

fix(agent): fall back for unrecognized CallToolResult content types#9691
Trainingcqy wants to merge 1 commit into
AstrBotDevs:masterfrom
Trainingcqy:fix/mcp-tool-result-unsupported-content

Conversation

@Trainingcqy

@Trainingcqy Trainingcqy commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

ToolLoopAgentRunner._handle_function_tools calls _append_tool_call_result to produce a result for the tool_call when the tool returns empty content, when resp is None, and when resp has an unexpected type; a tool that is not found and an exception raised during execution each have their own error text as well. But the content item dispatch has no such handling: its guard checks whether the content list is empty and misses the case where the list is non-empty yet none of its content items can be recognized.

CallToolResult is the common result type for every tool, and results from MCP tools, builtin tools and plugin tools all go through this one dispatch. The element type of its content is the MCP SDK's ContentBlock, which includes TextContent, ImageContent, AudioContent, ResourceLink and EmbeddedResource. The content item dispatch handles only three of them and has no default branch, so AudioContent and ResourceLink are never written into result_parts. When all content items are of uncovered types, result_parts is empty, if result_parts: does not hold, and that tool_call_id receives no tool message at all. When such an item is returned together with a covered type, it is silently dropped.

step() still writes the assistant message carrying tool_calls into the context unconditionally, so the next request violates the message sequence constraint and is rejected by the provider. done() also returns True for AgentState.ERROR, so that message is written back into the conversation history, and on OpenAI compatible endpoints every subsequent round fails with the same error until the session is reset. ContextTruncator.fix_messages() drops an assistant(tool_calls) that has no corresponding tool message, but it only runs when context truncation or compression actually happens, so a session that triggers neither is not protected by it. openai_source._sanitize_assistant_messages runs before every request, but the relevant part of it only removes orphaned or duplicate tool messages and does not handle tool_calls that were never answered. The content item is dropped inside the runner, before the provider layer, so this is unrelated to whether the model supports the corresponding modality. A tool result that conforms to the declared type of content is therefore enough to leave the session unusable.

This was encountered when reading an audio file through read_media_file from @modelcontextprotocol/server-filesystem. That tool dispatches on MIME type, returning a single AudioContent for audio files and an ImageContent for image files, so the same tool works on an image and fails on an audio file.

Modifications / 改动点

astrbot/core/agent/runners/tool_loop_agent_runner.py:

  • Add a default branch at the outer level of the content item if/elif chain, logging the type name of the unmatched content item and writing the unsupported type notice this function already uses. The branch follows the same form as the default branch at the resp level.

tests/test_tool_loop_agent_runner.py:

  • Add a mock executor that returns a single AudioContent, together with a case asserting that the set of tool_call_ids requested in the assistant message equals the set answered by tool messages.

Modifications / 改动点

astrbot/core/agent/runners/tool_loop_agent_runner.py:

  • Add a default branch at the outer level of the content item if/elif chain, logging the type name of the unmatched content item and writing the unsupported type notice this function already uses. The branch follows the same form as the default branch at the resp level.

tests/test_tool_loop_agent_runner.py:

  • Add a mock executor that returns a single AudioContent, together with a case asserting that the set of tool_call_ids requested in the assistant message equals the set answered by tool messages.

No new dependency, no change to the handling path of the already covered types, and no new user visible text.

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

The MCP server is @modelcontextprotocol/server-filesystem, with test.png (1×1 PNG) and test.wav (0.3s WAV, 4844 bytes) inside its allowed directory. Each case was run once on deepseek-v4-flash and once on gemini-3.6-flash.

Before the fix

Calling read_media_file on test.png in the same directory produces a tool result normally, and the role sequence contains tool.

Log deepseek-v4-flash test.png
[11:08:35.247] [INFO] [runners.tool_loop_agent_runner:1096]: Agent 使用工具: ['read_media_file']
[11:08:35.247] [INFO] [runners.tool_loop_agent_runner:1152]: 使用工具:read_media_file,参数:{'path': '.../test.png'}
[11:08:35.262] [INFO] [runners.tool_loop_agent_runner:1349]: Tool `read_media_file` Result: Image returned and cached at path='...\call_00_..._0.png'. Review the image below. ...
[11:08:35.262] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [4] system,user,assistant,tool
[11:08:43.504] [DBUG] [runners.base:64]: Agent state transition: AgentState.RUNNING -> AgentState.DONE

With test.wav the request is rejected. deepseek issued two tool calls in one round: read_text_file produced its result normally, while read_media_file produced nothing and left no error record, and the role sequence has only one tool message for the two tool_call_ids. Subsequent requests in the same session keep failing, and recover after a reset.

Log deepseek-v4-flash test.wav
[11:09:51.727] [INFO] [runners.tool_loop_agent_runner:1096]: Agent 使用工具: ['list_allowed_directories', 'get_file_info']
[11:09:51.740] [INFO] [runners.tool_loop_agent_runner:1349]: Tool `get_file_info` Result: size: 4844 ...
[11:09:51.744] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [5] system,user,assistant,tool,tool

[11:09:54.382] [INFO] [runners.tool_loop_agent_runner:1096]: Agent 使用工具: ['read_media_file', 'read_text_file']
[11:09:54.382] [INFO] [runners.tool_loop_agent_runner:1152]: 使用工具:read_media_file,参数:{'path': '.../test.wav'}
[11:09:54.392] [INFO] [runners.tool_loop_agent_runner:1152]: 使用工具:read_text_file,参数:{'path': '.../test.wav'}
[11:09:54.401] [INFO] [runners.tool_loop_agent_runner:1349]: Tool `read_text_file` Result: RIFF WAVEfmt @ >data ...
[11:09:54.402] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [7] system,user,assistant,tool,tool,assistant,tool
[11:09:55.463] [WARN] [runners.tool_loop_agent_runner:616]: Chat Model deepseek/deepseek-v4-flash request error:
    Error code: 400 - {'error': {'message': "An assistant message with 'tool_calls' must be followed by
    tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)",
    'type': 'invalid_request_error', 'param': None, 'code': 'invalid_request_error'}}
[11:09:55.747] [DBUG] [runners.base:64]: Agent state transition: AgentState.RUNNING -> AgentState.ERROR

# any message in the same session
[11:11:10.155] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [8] system,user,assistant,tool,tool,assistant,tool,user
[11:11:11.204] [WARN] [runners.tool_loop_agent_runner:616]: Chat Model deepseek/deepseek-v4-flash request error:
    Error code: 400 - {'error': {'message': "An assistant message with 'tool_calls' must be followed by
    tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)",
    'type': 'invalid_request_error', 'param': None, 'code': 'invalid_request_error'}}
[11:11:11.216] [DBUG] [runners.base:64]: Agent state transition: AgentState.RUNNING -> AgentState.ERROR

# after resetting the session
[11:12:19.168] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [2] system,user
[11:12:21.999] [DBUG] [runners.base:64]: Agent state transition: AgentState.RUNNING -> AgentState.DONE

Gemini rejects it as well, with a different error text. Its constraint is that a request must not end with a model turn, which applies only to the last message, so the next round recovers once a user message is appended and the failure is limited to a single round.

Log gemini-3.6-flash test.wav
[11:16:45.267] [INFO] [runners.tool_loop_agent_runner:1152]: 使用工具:get_file_info,参数:{'path': '.../test.wav'}
[11:16:45.274] [INFO] [runners.tool_loop_agent_runner:1349]: Tool `get_file_info` Result: size: 4844 ...
[11:16:45.275] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [4] system,user,assistant,tool

[11:16:47.267] [INFO] [runners.tool_loop_agent_runner:1152]: 使用工具:read_media_file,参数:{'path': '.../test.wav'}
[11:16:47.274] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [5] system,user,assistant,tool,assistant
[11:16:48.186] [WARN] [runners.tool_loop_agent_runner:616]: Chat Model google_gemini/gemini-3.6-flash request error:
    400 INVALID_ARGUMENT. {'error': {'code': 400, 'message': 'Requests ending with a model turn are not supported.',
    'status': 'INVALID_ARGUMENT'}}
[11:16:48.294] [DBUG] [runners.base:64]: Agent state transition: AgentState.RUNNING -> AgentState.ERROR

# recovers after a user message is appended in the next round
[11:17:07.466] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [6] system,user,assistant,tool,assistant,user
[11:17:12.281] [DBUG] [runners.base:64]: Agent state transition: AgentState.RUNNING -> AgentState.DONE

After the fix

With the same cases, neither provider returns 400. The log now contains Unsupported tool result content type: AudioContent, read_media_file produces a tool result, and every tool_call_id in the role sequence has a corresponding tool message.

Log deepseek-v4-flash
[00:08:33.526] [INFO] [runners.tool_loop_agent_runner:1096]: Agent 使用工具: ['read_media_file', 'read_file']
[00:08:33.535] [WARN] [runners.tool_loop_agent_runner:1270]: Unsupported tool result content type: AudioContent
[00:08:33.535] [INFO] [runners.tool_loop_agent_runner:1356]: Tool `read_media_file` Result: The tool has returned a data type that is not supported.
[00:08:33.546] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [8] system,user,assistant,tool,tool,assistant,tool,tool
[00:08:59.725] [DBUG] [runners.base:64]: Agent state transition: AgentState.RUNNING -> AgentState.DONE

# any message in the same session
[00:09:33.861] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [14] system,user,assistant,tool,...,assistant,tool,assistant,user
[00:09:36.391] [DBUG] [runners.base:64]: Agent state transition: AgentState.RUNNING -> AgentState.DONE

Log gemini-3.6-flash
[00:11:41.363] [INFO] [runners.tool_loop_agent_runner:1152]: 使用工具:get_file_info,参数:{'path': '.../test.wav'}
[00:11:41.370] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [4] system,user,assistant,tool

[00:11:44.161] [INFO] [runners.tool_loop_agent_runner:1152]: 使用工具:read_media_file,参数:{'path': '.../test.wav'}
[00:11:44.170] [WARN] [runners.tool_loop_agent_runner:1270]: Unsupported tool result content type: AudioContent
[00:11:44.170] [INFO] [runners.tool_loop_agent_runner:1356]: Tool `read_media_file` Result: The tool has returned a data type that is not supported.
[00:11:44.171] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [6] system,user,assistant,tool,assistant,tool
[00:11:55.751] [DBUG] [runners.base:64]: Agent state transition: AgentState.RUNNING -> AgentState.DONE

# next round
[00:12:02.888] [DBUG] [runners.tool_loop_agent_runner:676]: [BefCompact] messages -> [12] system,user,assistant,tool,...,assistant,tool,assistant,user
[00:12:04.915] [DBUG] [runners.base:64]: Agent state transition: AgentState.RUNNING -> AgentState.DONE

Based on that notice, the model tells the user it cannot read this type, for example:

由于当前模型接口暂不支持直接读取该格式的音频流(read_media_file 返回不受支持的数据类型),因此无法直接播放或转写音频内容。

Screenshots (before and after) 修复前截图 deepseek 修复前截图 gemini 修复后截图

Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Handle unsupported tool result content types without breaking tool_call/message pairing.

Bug Fixes:

  • Ensure tool results with unrecognized content item types still produce a tool message and preserve tool_call/message pairing.

Tests:

  • Add a regression test using a mock tool that returns AudioContent to verify all tool_calls receive corresponding tool messages.

CallToolResult.content items are typed as the ContentBlock union, which
includes TextContent, ImageContent, AudioContent, ResourceLink and
EmbeddedResource. The content item dispatch in _handle_function_tools
only handles TextContent, ImageContent and EmbeddedResource and has no
default branch, so AudioContent and ResourceLink items contribute
nothing to result_parts. When no content item matches any branch,
result_parts is empty and no tool result is appended, which leaves the
assistant tool_calls message unanswered and causes the provider to
reject the next request.

Append the unsupported-type notice this function already uses for any
content item that matches no branch, and log its type name.
@dosubot dosubot Bot added the size:XS This PR changes 0-9 lines, ignoring generated files. label Aug 14, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • The new default branch for unsupported content types duplicates the same fallback behavior as the existing resp-level default; consider extracting this into a small helper to keep the message text and logging behavior centralized and less error-prone to change later.
  • In the warning log for unsupported content (Unsupported tool result content type: {type(content_item).__name__}), you might include the underlying MCP type or a more structured representation (e.g., repr(content_item) with truncation) to make it easier to debug which tools and payloads are triggering this path.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new default branch for unsupported content types duplicates the same fallback behavior as the existing `resp`-level default; consider extracting this into a small helper to keep the message text and logging behavior centralized and less error-prone to change later.
- In the warning log for unsupported content (`Unsupported tool result content type: {type(content_item).__name__}`), you might include the underlying MCP type or a more structured representation (e.g., `repr(content_item)` with truncation) to make it easier to debug which tools and payloads are triggering this path.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XS This PR changes 0-9 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant