From 090f190fb68f89470ad0e7bff1936b7bb05ef471 Mon Sep 17 00:00:00 2001 From: vp Date: Fri, 6 Mar 2026 18:58:19 +0300 Subject: [PATCH 1/5] ui: prefer structured tool result previews --- src/fast_agent/ui/tool_display.py | 33 ++++++++++++++----- .../ui/test_shell_tool_result_display.py | 26 +++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/fast_agent/ui/tool_display.py b/src/fast_agent/ui/tool_display.py index 3a10e0ea5..71e260ce7 100644 --- a/src/fast_agent/ui/tool_display.py +++ b/src/fast_agent/ui/tool_display.py @@ -283,6 +283,21 @@ def show_tool_result( has_structured = structured_content is not None source_content = content display_content = content + if ( + has_structured + and isinstance(structured_content, (dict, list)) + and isinstance(content, list) + and len(content) > 1 + and all(is_text_content(item) for item in content) + ): + from mcp.types import TextContent + + display_content = [ + TextContent( + type="text", + text=json.dumps(structured_content, ensure_ascii=False, indent=2), + ) + ] if truncate_content: show_bash_output = self._shell_show_bash(tool_name) if not show_bash_output: @@ -310,22 +325,24 @@ def show_tool_result( if result.isError: status = "ERROR" else: - if not content: + if not display_content: status = "No Content" - elif len(content) == 1 and is_text_content(content[0]): - text_content = get_text(content[0]) + elif len(display_content) == 1 and is_text_content(display_content[0]): + text_content = get_text(display_content[0]) char_count = len(text_content) if text_content else 0 status = f"text only {char_count} chars" else: - text_count = sum(1 for item in content if is_text_content(item)) - if text_count == len(content): + text_count = sum(1 for item in display_content if is_text_content(item)) + if text_count == len(display_content): status = ( - f"{len(content)} Text Blocks" if len(content) > 1 else "1 Text Block" + f"{len(display_content)} Text Blocks" + if len(display_content) > 1 + else "1 Text Block" ) else: status = ( - f"{len(content)} Content Blocks" - if len(content) > 1 + f"{len(display_content)} Content Blocks" + if len(display_content) > 1 else "1 Content Block" ) diff --git a/tests/unit/fast_agent/ui/test_shell_tool_result_display.py b/tests/unit/fast_agent/ui/test_shell_tool_result_display.py index e8196f1f7..4b6e45bad 100644 --- a/tests/unit/fast_agent/ui/test_shell_tool_result_display.py +++ b/tests/unit/fast_agent/ui/test_shell_tool_result_display.py @@ -106,3 +106,29 @@ def test_shell_tool_result_parallel_deferred_uses_source_line_count() -> None: assert "line-10" not in rendered assert SHELL_OUTPUT_TRUNCATION_MARKER in rendered assert "12 lines" in rendered + + +def test_tool_result_prefers_structured_content_over_many_text_blocks() -> None: + display = ConsoleDisplay() + result = CallToolResult( + content=[ + TextContent(type="text", text='{"id":"a"}'), + TextContent(type="text", text='{"id":"b"}'), + ], + isError=False, + ) + setattr( + result, + "structuredContent", + {"result": [{"id": "a"}, {"id": "b"}]}, + ) + + with console.console.capture() as capture: + display.show_tool_result(result, name="dev", tool_name="voice__crm_tickets") + + rendered = capture.get() + assert '"result"' in rendered + assert '"id": "a"' in rendered + assert '"id": "b"' in rendered + assert "TextContent(" not in rendered + assert "text only" in rendered From ce1f7fdf3f5af0322337c81f60db4f5a0e6b9ae8 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:27:49 +0000 Subject: [PATCH 2/5] fix: restore docs as submodule --- docs | 1 + docs/requirements.txt | 34 ---------------------------------- 2 files changed, 1 insertion(+), 34 deletions(-) create mode 160000 docs delete mode 100644 docs/requirements.txt diff --git a/docs b/docs new file mode 160000 index 000000000..370cdaf80 --- /dev/null +++ b/docs @@ -0,0 +1 @@ +Subproject commit 370cdaf80ecb41d8f550f5e817eb5ae105177940 diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 551b666eb..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,34 +0,0 @@ -Babel==2.17.0 -certifi==2025.1.31 -charset-normalizer==3.4.1 -click==8.1.8 -colorama==0.4.6 -ghp-import==2.1.0 -idna==3.10 -Jinja2==3.1.6 -Markdown==3.7 -MarkupSafe==3.0.2 -mergedeep==1.3.4 -mkdocs==1.6.1 -mkdocs-get-deps==0.2.0 -mkdocs-llmstxt==0.1.0 -mkdocs-material==9.6.11 -mkdocs-material-extensions==1.3.1 -mkdocs-minify-plugin==0.8.0 -mkdocs-video>=1.5.0 -packaging==24.2 -paginate==0.5.7 -pathspec==0.12.1 -platformdirs==4.3.7 -Pygments==2.19.1 -pymdown-extensions==10.14.3 -python-dateutil==2.9.0.post0 -PyYAML==6.0.2 -pyyaml_env_tag==0.1 -regex==2024.11.6 -requests==2.32.4 -six==1.17.0 -urllib3==2.6.3 -watchdog==6.0.0 -pillow==11.2.1 -CairoSVG==2.7.1 From 8b4745884f1413093c2dda50dcd54f160c7d00b6 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:21:12 +0000 Subject: [PATCH 3/5] update structured content handling, with plan --- .../mcp/structured-content-preview/example.py | 34 ++ .../fastagent.config.yaml | 13 + .../preview_server.py | 75 +++ plan/03-07-structured-handling.md | 433 ++++++++++++++++++ src/fast_agent/llm/internal/passthrough.py | 4 +- .../multipart_converter_anthropic.py | 7 +- .../llm/provider/bedrock/llm_bedrock.py | 21 +- .../bedrock/multipart_converter_bedrock.py | 23 +- .../llm/provider/google/google_converter.py | 7 +- .../openai/multipart_converter_openai.py | 11 +- .../llm/provider/openai/responses_content.py | 18 +- src/fast_agent/mcp/helpers/content_helpers.py | 67 ++- .../agents/test_tool_runner_passthrough.py | 18 +- .../test_multipart_converter_openai.py | 28 ++ .../llm/providers/test_responses_helpers.py | 27 ++ .../fast_agent/mcp/test_content_helpers.py | 79 ++++ .../ui/test_shell_tool_result_display.py | 30 ++ 17 files changed, 865 insertions(+), 30 deletions(-) create mode 100644 examples/mcp/structured-content-preview/example.py create mode 100644 examples/mcp/structured-content-preview/fastagent.config.yaml create mode 100644 examples/mcp/structured-content-preview/preview_server.py create mode 100644 plan/03-07-structured-handling.md create mode 100644 tests/unit/fast_agent/mcp/test_content_helpers.py diff --git a/examples/mcp/structured-content-preview/example.py b/examples/mcp/structured-content-preview/example.py new file mode 100644 index 000000000..d98cf835e --- /dev/null +++ b/examples/mcp/structured-content-preview/example.py @@ -0,0 +1,34 @@ +"""Manual demo for structuredContent preview behavior. + +Run from this directory: + uv run example.py +""" + +from __future__ import annotations + +import asyncio + +from fast_agent import FastAgent + +fast = FastAgent("Structured content preview demo") + + +@fast.agent( + instruction=( + "Use the passthrough model to invoke the requested tool exactly as provided." + ), + servers=["structured_preview"], +) +async def main() -> None: + async with fast.run() as agent: + print("\n=== matching text + structuredContent ===") + matching = await agent.send("***CALL_TOOL structured_content_match {}") + print(matching) + + print("\n=== mismatched text + structuredContent ===") + mismatched = await agent.send("***CALL_TOOL structured_content_mismatch {}") + print(mismatched) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/mcp/structured-content-preview/fastagent.config.yaml b/examples/mcp/structured-content-preview/fastagent.config.yaml new file mode 100644 index 000000000..044b6ba28 --- /dev/null +++ b/examples/mcp/structured-content-preview/fastagent.config.yaml @@ -0,0 +1,13 @@ +default_model: "passthrough" + +logger: + level: "error" + type: "console" + show_chat: false + show_tools: true + truncate_tools: false + +mcp: + targets: + - name: structured_preview + target: "uv run preview_server.py" diff --git a/examples/mcp/structured-content-preview/preview_server.py b/examples/mcp/structured-content-preview/preview_server.py new file mode 100644 index 000000000..e6584ec94 --- /dev/null +++ b/examples/mcp/structured-content-preview/preview_server.py @@ -0,0 +1,75 @@ +"""MCP server for exercising structured tool result previews. + +Run with: + uv run preview_server.py +""" + +from __future__ import annotations + +import json +from typing import Any + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent + +app = FastMCP(name="Structured Content Preview Demo") + + +def _text_block(payload: Any) -> TextContent: + return TextContent( + type="text", + text=json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + ) + + +def _tool_result(*, text_payloads: list[Any], structured_payload: dict[str, Any]) -> CallToolResult: + result = CallToolResult( + content=[_text_block(payload) for payload in text_payloads], + isError=False, + ) + setattr(result, "structuredContent", structured_payload) + return result + + +@app.tool( + name="structured_content_match", + description=( + "Return multiple text blocks that match the structuredContent payload. " + "Useful for checking the new preview path." + ), +) +def structured_content_match() -> CallToolResult: + tickets = [ + {"ticket_id": "T-100", "status": "open", "owner": "alex"}, + {"ticket_id": "T-101", "status": "pending", "owner": "sam"}, + ] + return _tool_result( + text_payloads=tickets, + structured_payload={"tickets": tickets, "match_state": "match"}, + ) + + +@app.tool( + name="structured_content_mismatch", + description=( + "Return multiple text blocks that do not match structuredContent. " + "Useful for seeing how the preview behaves when the two disagree." + ), +) +def structured_content_mismatch() -> CallToolResult: + text_tickets = [ + {"ticket_id": "T-100", "status": "closed", "owner": "alex"}, + {"ticket_id": "T-101", "status": "pending", "owner": "sam"}, + ] + structured_tickets = [ + {"ticket_id": "T-100", "status": "open", "owner": "alex"}, + {"ticket_id": "T-101", "status": "escalated", "owner": "sam"}, + ] + return _tool_result( + text_payloads=text_tickets, + structured_payload={"tickets": structured_tickets, "match_state": "mismatch"}, + ) + + +if __name__ == "__main__": + app.run(transport="stdio") diff --git a/plan/03-07-structured-handling.md b/plan/03-07-structured-handling.md new file mode 100644 index 000000000..e941b108d --- /dev/null +++ b/plan/03-07-structured-handling.md @@ -0,0 +1,433 @@ +# Structured Tool Result Handling Plan + +## Goal + +Align LLM-facing tool result serialization with the MCP `structuredContent` field when it is present, while keeping provider-specific changes small and predictable. + +Today there is a mismatch: + +- the UI preview path prefers `structuredContent` for display in some cases +- provider serialization paths send `result.content` to the model +- the passthrough model narrows this even further and currently uses only `TextContent[0]` + +That creates a bad failure mode: + +- the user sees one payload in the tool result panel +- the model reasons over a different payload + +This plan makes `structuredContent` the canonical source for LLM-facing text when present, but does so in a centralized helper layer instead of scattering policy across providers. + +--- + +## Current State + +### UI already treats `structuredContent` specially + +The preview logic in: + +- `src/fast_agent/ui/tool_display.py` + +will replace multiple text blocks with pretty-printed JSON from `structuredContent` for display purposes. + +This is a UI-only transformation. + +### Providers currently ignore `structuredContent` + +The current tool-result serialization paths all work from `result.content`: + +- `src/fast_agent/llm/provider/openai/multipart_converter_openai.py` +- `src/fast_agent/llm/provider/openai/responses_content.py` +- `src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py` +- `src/fast_agent/llm/provider/google/google_converter.py` +- `src/fast_agent/llm/provider/bedrock/llm_bedrock.py` +- `src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py` +- `src/fast_agent/llm/internal/passthrough.py` + +As a result, `structuredContent` is not currently part of the model-visible tool result unless some server already duplicated it into text blocks. + +### MCP intent vs practice + +The MCP shape expects the text content and `structuredContent` to agree semantically, but it does not enforce that invariant. In practice, tools sometimes: + +- return several `TextContent` blocks instead of one +- return text that is stale, lossy, or human-oriented +- diverge from the actual `structuredContent` + +We should therefore explicitly pick a canonical source rather than assuming these fields always agree. + +--- + +## Recommendation + +Introduce a centralized canonicalization helper for tool results in the content helper layer. + +Rule: + +- if `structuredContent` is absent, preserve current behavior and use `result.content` +- if `structuredContent` is present, synthesize a single canonical JSON `TextContent` for LLM text serialization +- preserve non-text blocks from `result.content` unchanged + +This keeps the policy in one place and limits provider edits to swapping raw `result.content` iteration for helper output. + +--- + +## Proposed Design + +### 1. Add a canonical LLM-view helper + +Add helper functions in: + +- `src/fast_agent/mcp/helpers/content_helpers.py` + +Recommended shape: + +- `canonicalize_tool_result_content_for_llm(result, logger=None, source=None) -> list[ContentBlock]` +- optionally `tool_result_text_for_llm(result, logger=None, source=None) -> str` + +Core behavior: + +1. Inspect `result.content` +2. Inspect `getattr(result, "structuredContent", None)` +3. If no `structuredContent`: + - return the original logical content view +4. If `structuredContent` is present: + - gather all non-text content blocks from `result.content` + - replace all text blocks with one synthesized `TextContent` + - synthesized text is compact JSON from `structuredContent` + - return `[synthetic_text_block, *non_text_blocks]` + +This helper should not mutate the original `CallToolResult`. + +### 2. JSON serialization policy + +For the synthetic text block: + +- use `json.dumps(structured_content, ensure_ascii=False, sort_keys=True, separators=(",", ":"))` + +Rationale: + +- compact JSON is cheaper for model input than indented JSON +- `ensure_ascii=False` preserves readable Unicode +- `sort_keys=True` improves determinism in tests and logs + +The UI can continue to pretty-print separately for human readability. The LLM path should optimize for canonicality and compactness. + +### 3. Warning policy + +Emit a warning only in the narrow case that is most likely to indicate divergence or unexpected formatting: + +- `structuredContent` is present +- there are more than one `TextContent` blocks in `result.content` + +Warning message should make the behavior explicit: + +- multiple text blocks were present alongside `structuredContent` +- fast-agent is ignoring those text blocks for LLM text serialization +- `structuredContent` is being used as the canonical text payload + +This warning should be best-effort and non-fatal. + +### 4. Comment the spec divergence clearly + +The helper should contain a short explanatory comment stating: + +- MCP intends text content and `structuredContent` to agree +- that invariant is not enforced in practice +- fast-agent prefers `structuredContent` for LLM-facing text to avoid divergence between model input and displayed structured preview + +This comment matters because otherwise the helper will look like a surprising override of user-provided text blocks. + +--- + +## Why the Content Layer Is the Right Place + +This change is a policy decision about canonical representation of a tool result, not a provider capability decision. + +Placing it in the content helper layer has these benefits: + +- one place defines the rule +- providers remain thin serializers +- warning behavior is shared and consistent +- future provider implementations inherit the policy automatically +- tests can target the policy once rather than re-testing each provider in depth + +This is the cleanest way to keep provider blast radius manageable. + +--- + +## Provider Impact + +Provider changes should be minimal and mechanical. + +### OpenAI chat + +Current file: + +- `src/fast_agent/llm/provider/openai/multipart_converter_openai.py` + +Change: + +- when building tool response messages, iterate the canonicalized helper output rather than raw `tool_result.content` + +### OpenAI Responses + +Current file: + +- `src/fast_agent/llm/provider/openai/responses_content.py` + +Change: + +- `_tool_result_to_text(...)` should use canonicalized content +- `_tool_result_to_input_parts(...)` should preserve non-text attachments while using canonical text + +### Anthropic + +Current file: + +- `src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py` + +Change: + +- build tool result blocks from canonicalized content + +### Google + +Current file: + +- `src/fast_agent/llm/provider/google/google_converter.py` + +Change: + +- collect textual outputs from canonicalized content +- continue preserving media/resource parts as today + +### Bedrock + +Current files: + +- `src/fast_agent/llm/provider/bedrock/llm_bedrock.py` +- `src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py` + +Change: + +- use canonicalized content in both the instance-based and static conversion paths + +### Passthrough + +Current file: + +- `src/fast_agent/llm/internal/passthrough.py` + +Change: + +- stop using `tool_result.content[0]` +- build passthrough text from the canonicalized text view instead + +This is especially important because passthrough currently has the sharpest reduction of tool-result fidelity. + +--- + +## Trade-offs + +### Trade-off 1: prefer `structuredContent` over textual prose + +Decision: + +- when `structuredContent` is present, it becomes the canonical source for LLM-facing text + +Pros: + +- avoids user-visible/model-visible divergence +- aligns the model with the structured payload rather than potentially stale prose +- deterministic and easy to reason about + +Cons: + +- if a tool intentionally included useful narrative explanation in text blocks, that prose will no longer be the primary text passed to the model +- some tools may rely on descriptive text phrasing rather than pure data + +Why this trade-off is acceptable: + +- the presence of `structuredContent` is a strong signal that the structured payload is the authoritative result +- explanatory text can still be preserved in future if needed, but using divergent text as canonical input is the riskier default + +### Trade-off 2: coerce structure to JSON string rather than provider-native structure + +Decision: + +- convert `structuredContent` to JSON text for current provider input paths + +Pros: + +- integrates cleanly with all existing providers +- avoids provider-specific schema branching +- keeps blast radius small + +Cons: + +- loses native structured semantics at the transport boundary +- model sees structure as text rather than an explicitly typed object + +Why this trade-off is acceptable: + +- all current tool-result serialization paths already operate on text/media/resource content +- introducing provider-native structured tool-result objects would be a much larger redesign + +### Trade-off 3: warn on multiple text blocks, but do not attempt semantic comparison + +Decision: + +- warn only for the presence of multiple text blocks alongside `structuredContent` + +Pros: + +- low noise +- easy to implement +- catches a likely unexpected shape + +Cons: + +- does not detect divergence when there is exactly one misleading text block +- does not measure semantic mismatch directly + +Why this trade-off is acceptable: + +- semantic comparison would require parsing arbitrary text and would likely be noisy and brittle +- this plan favors a reliable canonicalization rule over speculative validation + +### Trade-off 4: preserve non-text blocks + +Decision: + +- keep non-text `result.content` blocks intact when `structuredContent` is present + +Pros: + +- preserves multimodal behavior +- minimizes regression risk for image/document/resource tool results + +Cons: + +- canonicalized text plus preserved attachments can still produce mixed payloads + +Why this trade-off is acceptable: + +- we are changing only text canonicalization, not the broader multimodal contract +- removing non-text blocks would be a much more disruptive change + +--- + +## Non-Goals + +This plan does not attempt to: + +- enforce MCP server correctness at the server boundary +- compare text content and `structuredContent` semantically +- introduce a new `CallToolResult` schema +- redesign provider APIs around native structured tool outputs +- change UI preview behavior beyond keeping it conceptually aligned with the new LLM policy + +--- + +## Implementation Slices + +### Slice 1: helper layer + +Files: + +- `src/fast_agent/mcp/helpers/content_helpers.py` + +Changes: + +- add canonical tool-result helper(s) +- add docstring/comment explaining MCP divergence and chosen canonicalization rule +- add optional warning support via passed logger + +### Slice 2: provider adoption + +Files: + +- `src/fast_agent/llm/provider/openai/multipart_converter_openai.py` +- `src/fast_agent/llm/provider/openai/responses_content.py` +- `src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py` +- `src/fast_agent/llm/provider/google/google_converter.py` +- `src/fast_agent/llm/provider/bedrock/llm_bedrock.py` +- `src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py` +- `src/fast_agent/llm/internal/passthrough.py` + +Changes: + +- replace direct raw text iteration with canonical helper usage +- preserve current provider-specific handling for images/resources + +### Slice 3: tests + +Add focused tests for: + +- helper behavior with no `structuredContent` +- helper behavior with matching `structuredContent` +- helper behavior with divergent text blocks +- warning emission when multiple text blocks are present +- one or two targeted provider-path tests to confirm canonical text is used + +Recommended initial test locations: + +- `tests/unit/fast_agent/mcp/...` for helper policy +- existing provider unit test files where tool-result serialization is already covered +- passthrough unit tests, since that path currently has the narrowest behavior + +### Slice 4: verification + +Run: + +- targeted unit tests for helper/provider paths +- `uv run scripts/lint.py` +- `uv run scripts/typecheck.py` + +--- + +## Risks + +### Behavioral regression for tools that relied on text prose + +Some tools may have been relying on prose-oriented text blocks as the model-facing summary even when `structuredContent` was present. + +Mitigation: + +- keep the rule narrow and explicit +- cover changed behavior in tests +- document the trade-off in code comments and changelog/PR notes if needed + +### Provider inconsistency if one path is missed + +If a provider path keeps using raw `result.content`, we would still have inconsistent model behavior. + +Mitigation: + +- centralize the policy in helpers +- audit all known tool-result serializers in this plan +- add at least one provider-path regression test plus passthrough coverage + +### Log noise + +Warnings could become noisy if many third-party tools emit multiple text blocks. + +Mitigation: + +- only warn in the narrow multi-text-block case +- do not warn when `structuredContent` is absent +- do not warn on every ordinary single-block structured tool result + +--- + +## Recommendation Summary + +Recommended implementation: + +1. canonicalize tool-result text in the content helper layer +2. prefer `structuredContent` whenever it is present +3. preserve non-text attachments +4. warn when multiple text blocks accompany `structuredContent` +5. keep provider changes minimal and mechanical + +This is the lowest-risk way to make displayed tool results and model-visible tool results agree without expanding the change into a provider-specific redesign. diff --git a/src/fast_agent/llm/internal/passthrough.py b/src/fast_agent/llm/internal/passthrough.py index bb896a612..1343e72d3 100644 --- a/src/fast_agent/llm/internal/passthrough.py +++ b/src/fast_agent/llm/internal/passthrough.py @@ -12,7 +12,7 @@ ) from fast_agent.llm.provider_types import Provider from fast_agent.llm.usage_tracking import create_turn_usage_from_messages -from fast_agent.mcp.helpers.content_helpers import get_text +from fast_agent.mcp.helpers.content_helpers import tool_result_text_for_llm from fast_agent.types import PromptMessageExtended from fast_agent.types.llm_stop_reason import LlmStopReason @@ -105,7 +105,7 @@ async def _apply_prompt_provider_specific( assert last_message.tool_results concatenated_content = " ".join( [ - (get_text(tool_result.content[0]) or "") + (tool_result_text_for_llm(tool_result, logger=self.logger, source="passthrough") or "") for tool_result in last_message.tool_results.values() ] ) diff --git a/src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py b/src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py index b08d7099f..c6a88df55 100644 --- a/src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +++ b/src/fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py @@ -72,6 +72,7 @@ from fast_agent.core.logging.logger import get_logger from fast_agent.llm.provider.anthropic.web_tools import is_server_tool_trace_payload from fast_agent.mcp.helpers.content_helpers import ( + canonicalize_tool_result_content_for_llm, get_image_data, get_resource_uri, get_text, @@ -695,7 +696,11 @@ def create_tool_results_message( tool_result_blocks = [] # Process each content item in the result - for item in result.content: + for item in canonicalize_tool_result_content_for_llm( + result, + logger=_logger, + source="anthropic", + ): if isinstance(item, (TextContent, ImageContent)): blocks = AnthropicConverter._convert_content_items([item], document_mode=False) tool_result_blocks.extend(blocks) diff --git a/src/fast_agent/llm/provider/bedrock/llm_bedrock.py b/src/fast_agent/llm/provider/bedrock/llm_bedrock.py index 02d17dc6c..86936b008 100644 --- a/src/fast_agent/llm/provider/bedrock/llm_bedrock.py +++ b/src/fast_agent/llm/provider/bedrock/llm_bedrock.py @@ -28,6 +28,10 @@ validate_reasoning_setting, ) from fast_agent.llm.usage_tracking import TurnUsage +from fast_agent.mcp.helpers.content_helpers import ( + canonicalize_tool_result_content_for_llm, + tool_result_text_for_llm, +) from fast_agent.types import PromptMessageExtended, RequestParams from fast_agent.types.llm_stop_reason import LlmStopReason @@ -956,8 +960,10 @@ def _convert_multipart_to_bedrock_message( # For system prompt models: format as human-readable text tool_result_parts = [] for tool_id, tool_result in msg.tool_results.items(): - result_text = "".join( - part.text for part in tool_result.content if isinstance(part, TextContent) + result_text = tool_result_text_for_llm( + tool_result, + logger=self.logger, + source="bedrock", ) result_payload = { "tool_name": tool_id, # Use tool_id as name for system prompt @@ -973,10 +979,13 @@ def _convert_multipart_to_bedrock_message( # For Nova/Anthropic models: use structured tool_result format for tool_id, tool_result in msg.tool_results.items(): result_content_blocks = [] - if tool_result.content: - for part in tool_result.content: - if isinstance(part, TextContent): - result_content_blocks.append({"text": part.text}) + for part in canonicalize_tool_result_content_for_llm( + tool_result, + logger=self.logger, + source="bedrock", + ): + if isinstance(part, TextContent): + result_content_blocks.append({"text": part.text}) if not result_content_blocks: result_content_blocks.append({"text": "[No content in tool result]"}) diff --git a/src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py b/src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py index 215c0d1bc..d5f1e5619 100644 --- a/src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +++ b/src/fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py @@ -1,5 +1,11 @@ from typing import Any +from mcp.types import TextContent + +from fast_agent.mcp.helpers.content_helpers import ( + canonicalize_tool_result_content_for_llm, + tool_result_text_for_llm, +) from fast_agent.types import PromptMessageExtended # Bedrock message format types @@ -31,8 +37,6 @@ def convert_to_bedrock(multipart_msg: PromptMessageExtended) -> BedrockMessagePa if multipart_msg.tool_results: import json - from mcp.types import TextContent - # Check if any tool ID indicates system prompt format has_system_prompt_tools = any( tool_id.startswith("system_prompt_") for tool_id in multipart_msg.tool_results.keys() @@ -42,9 +46,7 @@ def convert_to_bedrock(multipart_msg: PromptMessageExtended) -> BedrockMessagePa # For system prompt models: format as human-readable text tool_result_parts = [] for tool_id, tool_result in multipart_msg.tool_results.items(): - result_text = "".join( - part.text for part in tool_result.content if isinstance(part, TextContent) - ) + result_text = tool_result_text_for_llm(tool_result, source="bedrock.static") result_payload = { "tool_name": tool_id, "status": "error" if tool_result.isError else "success", @@ -59,10 +61,12 @@ def convert_to_bedrock(multipart_msg: PromptMessageExtended) -> BedrockMessagePa # For Nova/Anthropic models: use structured tool_result format for tool_id, tool_result in multipart_msg.tool_results.items(): result_content_blocks = [] - if tool_result.content: - for part in tool_result.content: - if isinstance(part, TextContent): - result_content_blocks.append({"text": part.text}) + for part in canonicalize_tool_result_content_for_llm( + tool_result, + source="bedrock.static", + ): + if isinstance(part, TextContent): + result_content_blocks.append({"text": part.text}) if not result_content_blocks: result_content_blocks.append({"text": "[No content in tool result]"}) @@ -89,7 +93,6 @@ def convert_to_bedrock(multipart_msg: PromptMessageExtended) -> BedrockMessagePa ) # Handle regular content - from mcp.types import TextContent for content_item in multipart_msg.content: if isinstance(content_item, TextContent): content_list.append({"type": "text", "text": content_item.text}) diff --git a/src/fast_agent/llm/provider/google/google_converter.py b/src/fast_agent/llm/provider/google/google_converter.py index 0157d517d..c06ce8d38 100644 --- a/src/fast_agent/llm/provider/google/google_converter.py +++ b/src/fast_agent/llm/provider/google/google_converter.py @@ -19,6 +19,7 @@ from pydantic import AnyUrl from fast_agent.mcp.helpers.content_helpers import ( + canonicalize_tool_result_content_for_llm, get_image_data, get_text, is_image_content, @@ -308,7 +309,11 @@ def convert_function_results_to_google( textual_outputs: list[str] = [] media_parts: list[types.Part] = [] - for item in tool_result.content: + canonical_content = canonicalize_tool_result_content_for_llm( + tool_result, + source="google", + ) + for item in canonical_content: if is_text_content(item): textual_outputs.append(get_text(item) or "") # Ensure no None is added elif is_image_content(item): diff --git a/src/fast_agent/llm/provider/openai/multipart_converter_openai.py b/src/fast_agent/llm/provider/openai/multipart_converter_openai.py index e2367745c..749539d66 100644 --- a/src/fast_agent/llm/provider/openai/multipart_converter_openai.py +++ b/src/fast_agent/llm/provider/openai/multipart_converter_openai.py @@ -18,6 +18,7 @@ from fast_agent.core.logging.logger import get_logger from fast_agent.mcp.helpers.content_helpers import ( + canonicalize_tool_result_content_for_llm, get_image_data, get_resource_uri, get_text, @@ -459,8 +460,14 @@ def convert_tool_result_to_openai( Either a single OpenAI message for the tool response (if text only), or a tuple containing the tool message and a list of additional messages for non-text content """ + canonical_content = canonicalize_tool_result_content_for_llm( + tool_result, + logger=_logger, + source="openai.chat", + ) + # Handle empty content case - if not tool_result.content: + if not canonical_content: return ChatCompletionToolMessageParam( role="tool", tool_call_id=tool_call_id, @@ -471,7 +478,7 @@ def convert_tool_result_to_openai( text_content = [] non_text_content = [] - for item in tool_result.content: + for item in canonical_content: if isinstance(item, TextContent): text_content.append(item) else: diff --git a/src/fast_agent/llm/provider/openai/responses_content.py b/src/fast_agent/llm/provider/openai/responses_content.py index 691877d33..69227129f 100644 --- a/src/fast_agent/llm/provider/openai/responses_content.py +++ b/src/fast_agent/llm/provider/openai/responses_content.py @@ -11,6 +11,7 @@ REASONING, ) from fast_agent.mcp.helpers.content_helpers import ( + canonicalize_tool_result_content_for_llm, get_image_data, get_resource_uri, get_text, @@ -413,7 +414,12 @@ def _convert_tool_results( call_id = self._tool_call_id_map.get(tool_use_id) if not call_id: call_id = normalized_call_id - output = self._tool_result_to_text(result) + canonical_content = canonicalize_tool_result_content_for_llm( + result, + logger=self.logger, + source="openai.responses", + ) + output = self._tool_result_content_to_text(canonical_content) tool_kind = self._resolve_tool_call_kind( tool_use_id=tool_use_id, fc_id=fc_id, @@ -435,7 +441,7 @@ def _convert_tool_results( "output": output, } ) - attachment_parts = self._tool_result_to_input_parts(result) + attachment_parts = self._tool_result_content_to_input_parts(canonical_content) if attachment_parts: items.append( { @@ -446,8 +452,7 @@ def _convert_tool_results( ) return items - def _tool_result_to_text(self, result: Any) -> str: - contents = getattr(result, "content", None) or [] + def _tool_result_content_to_text(self, contents: list[ContentBlock]) -> str: chunks: list[str] = [] for item in contents: text = get_text(item) @@ -466,8 +471,9 @@ def _tool_result_to_text(self, result: Any) -> str: chunks.append(f"[Unsupported content: {type(item).__name__}]") return "\n".join(chunk for chunk in chunks if chunk) - def _tool_result_to_input_parts(self, result: Any) -> list[dict[str, Any]]: - contents = getattr(result, "content", None) or [] + def _tool_result_content_to_input_parts( + self, contents: list[ContentBlock] + ) -> list[dict[str, Any]]: parts: list[dict[str, Any]] = [] for item in contents: if is_image_content(item) or is_resource_content(item): diff --git a/src/fast_agent/mcp/helpers/content_helpers.py b/src/fast_agent/mcp/helpers/content_helpers.py index 1f5b25a9b..3cb9a52b4 100644 --- a/src/fast_agent/mcp/helpers/content_helpers.py +++ b/src/fast_agent/mcp/helpers/content_helpers.py @@ -3,7 +3,8 @@ """ -from typing import TYPE_CHECKING, Sequence, TypeGuard, Union +import json +from typing import TYPE_CHECKING, Protocol, Sequence, TypeGuard, Union if TYPE_CHECKING: from fast_agent.mcp.prompt_message_extended import PromptMessageExtended @@ -21,6 +22,12 @@ ) +class ToolResultWarningLogger(Protocol): + """Minimal logger interface used for best-effort tool result warnings.""" + + def warning(self, message: str, **data: object) -> None: ... + + def get_text(content: ContentBlock) -> str | None: """Extract text content from a content object if available.""" if isinstance(content, TextContent): @@ -129,6 +136,64 @@ def text_content(text: str) -> TextContent: return TextContent(type="text", text=text) +def canonicalize_tool_result_content_for_llm( + result: object, + logger: ToolResultWarningLogger | None = None, + source: str | None = None, +) -> list[ContentBlock]: + """Return the canonical LLM-facing content view for a tool result. + + MCP intends `content` text and `structuredContent` to agree semantically, + but that invariant is not enforced in practice. fast-agent therefore + prefers `structuredContent` for LLM-facing text when it is present so the + model sees the same canonical payload that the UI preview favors. + """ + + raw_content = getattr(result, "content", None) + content = list(raw_content) if isinstance(raw_content, list) else [] + + structured_content = getattr(result, "structuredContent", None) + if structured_content is None: + return content + + text_blocks = [item for item in content if is_text_content(item)] + if logger is not None and len(text_blocks) > 1: + warning_data: dict[str, object] = {"text_block_count": len(text_blocks)} + if source is not None: + warning_data["source"] = source + logger.warning( + "Tool result includes multiple text blocks alongside structuredContent; " + "ignoring those text blocks for LLM serialization and using " + "structuredContent as the canonical text payload.", + **warning_data, + ) + + non_text_blocks = [item for item in content if not is_text_content(item)] + structured_text = json.dumps( + structured_content, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return [text_content(structured_text), *non_text_blocks] + + +def tool_result_text_for_llm( + result: object, + logger: ToolResultWarningLogger | None = None, + source: str | None = None, +) -> str: + """Return the textual LLM-facing view of a tool result.""" + + canonical_content = canonicalize_tool_result_content_for_llm( + result, + logger=logger, + source=source, + ) + text_chunks = [text for item in canonical_content if (text := get_text(item)) is not None] + return "\n".join(chunk for chunk in text_chunks if chunk) + + def _infer_mime_type(url: str, default: str = "application/octet-stream") -> str: """Infer MIME type from URL using the mimetypes database.""" from urllib.parse import urlparse diff --git a/tests/unit/fast_agent/agents/test_tool_runner_passthrough.py b/tests/unit/fast_agent/agents/test_tool_runner_passthrough.py index 656c2bff9..dbbcf4d81 100644 --- a/tests/unit/fast_agent/agents/test_tool_runner_passthrough.py +++ b/tests/unit/fast_agent/agents/test_tool_runner_passthrough.py @@ -1,6 +1,6 @@ import pytest from mcp import CallToolRequest -from mcp.types import CallToolRequestParams, Tool +from mcp.types import CallToolRequestParams, CallToolResult, Tool from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.tool_agent import ToolAgent @@ -163,3 +163,19 @@ async def test_postprocess_mode_remains_default_behavior() -> None: assert llm.call_count == 2 assert result.stop_reason == LlmStopReason.END_TURN assert result.last_text() == "postprocessed" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_passthrough_uses_structured_content_for_tool_result_text() -> None: + llm = PassthroughLLM() + tool_result = CallToolResult( + content=[text_content("stale summary")], + isError=False, + ) + setattr(tool_result, "structuredContent", {"b": 2, "a": 1}) + message = PromptMessageExtended(role="user", content=[], tool_results={"call_1": tool_result}) + + result = await llm._apply_prompt_provider_specific([message]) + + assert result.last_text() == '{"a":1,"b":2}' diff --git a/tests/unit/fast_agent/llm/providers/test_multipart_converter_openai.py b/tests/unit/fast_agent/llm/providers/test_multipart_converter_openai.py index b9859fa1e..5c598c8a8 100644 --- a/tests/unit/fast_agent/llm/providers/test_multipart_converter_openai.py +++ b/tests/unit/fast_agent/llm/providers/test_multipart_converter_openai.py @@ -526,6 +526,34 @@ def test_tool_result_with_mixed_content(self): f"data:application/pdf;base64,{pdf_base64}", ) + def test_tool_result_prefers_structured_content_for_tool_text(self): + """Test that structuredContent becomes the canonical tool text payload.""" + image_base64 = base64.b64encode(b"fake_image_data").decode("utf-8") + image_content = ImageContent(type="image", data=image_base64, mimeType="image/jpeg") + tool_result = CallToolResult( + content=[ + TextContent(type="text", text="stale summary"), + TextContent(type="text", text="ignored detail"), + image_content, + ], + isError=False, + ) + setattr(tool_result, "structuredContent", {"status": "fresh", "value": 3}) + + converted = OpenAIConverter.convert_tool_result_to_openai( + tool_result=tool_result, + tool_call_id="call_structured", + ) + + assert isinstance(converted, tuple) + tool_msg, user_messages = converted + tool_msg = cast("ChatCompletionToolMessageParam", tool_msg) + user_msg = cast("ChatCompletionUserMessageParam", user_messages[0]) + + self.assertEqual(tool_msg["content"], '{"status":"fresh","value":3}') + self.assertEqual(user_msg["role"], "user") + self.assertEqual(content_parts(user_msg)[0]["type"], "image_url") + def test_empty_schema_behavior(self): """Test adjustment of parameters for empty schema.""" inputSchema = { diff --git a/tests/unit/fast_agent/llm/providers/test_responses_helpers.py b/tests/unit/fast_agent/llm/providers/test_responses_helpers.py index dc756f15a..f2db0ec84 100644 --- a/tests/unit/fast_agent/llm/providers/test_responses_helpers.py +++ b/tests/unit/fast_agent/llm/providers/test_responses_helpers.py @@ -174,6 +174,33 @@ def test_convert_tool_results_serializes_apply_patch_as_custom_tool_call_output( ] +def test_convert_tool_results_prefers_structured_content_and_keeps_attachments() -> None: + harness = _ContentHarness() + image_data = base64.b64encode(b"fake-image").decode("utf-8") + result = SimpleNamespace( + content=[ + TextContent(type="text", text="stale summary"), + ImageContent(type="image", data=image_data, mimeType="image/jpeg"), + ], + isError=False, + ) + setattr(result, "structuredContent", {"fresh": True}) + + items = harness._convert_tool_results({"call_1": result}) + + assert items[0]["type"] == "function_call_output" + assert items[0]["call_id"] == "call_1" + assert items[0]["output"].splitlines()[0] == '{"fresh":true}' + assert "stale summary" not in items[0]["output"] + assert items[1]["type"] == "message" + assert items[1]["role"] == "user" + attachments = items[1]["content"] + assert isinstance(attachments, list) + assert attachments == [ + {"type": "input_image", "image_url": f"data:image/jpeg;base64,{image_data}"} + ] + + def test_convert_tool_calls_keeps_namespaced_apply_patch_as_function_call() -> None: harness = _ContentHarness() harness._tool_kind_map["call_patch"] = "function" diff --git a/tests/unit/fast_agent/mcp/test_content_helpers.py b/tests/unit/fast_agent/mcp/test_content_helpers.py new file mode 100644 index 000000000..378a841d8 --- /dev/null +++ b/tests/unit/fast_agent/mcp/test_content_helpers.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import base64 + +from mcp.types import CallToolResult, ImageContent, TextContent + +from fast_agent.mcp.helpers.content_helpers import ( + canonicalize_tool_result_content_for_llm, + tool_result_text_for_llm, +) + + +class _LoggerSpy: + def __init__(self) -> None: + self.warning_calls: list[tuple[str, dict[str, object]]] = [] + + def warning(self, message: str, **data: object) -> None: + self.warning_calls.append((message, data)) + + +def test_canonicalize_tool_result_content_preserves_original_content_without_structured_content() -> None: + image_data = base64.b64encode(b"fake-image").decode("utf-8") + text_block = TextContent(type="text", text="hello") + image_block = ImageContent(type="image", data=image_data, mimeType="image/jpeg") + result = CallToolResult(content=[text_block, image_block], isError=False) + + canonical = canonicalize_tool_result_content_for_llm(result) + + assert canonical is not result.content + assert canonical[0] is text_block + assert canonical[1] is image_block + + +def test_canonicalize_tool_result_content_prefers_structured_content_and_preserves_non_text() -> None: + image_data = base64.b64encode(b"fake-image").decode("utf-8") + image_block = ImageContent(type="image", data=image_data, mimeType="image/jpeg") + result = CallToolResult( + content=[TextContent(type="text", text="stale summary"), image_block], + isError=False, + ) + setattr(result, "structuredContent", {"z": 3, "a": 1}) + + canonical = canonicalize_tool_result_content_for_llm(result) + + assert len(canonical) == 2 + assert isinstance(canonical[0], TextContent) + assert canonical[0].text == '{"a":1,"z":3}' + assert canonical[1] is image_block + + +def test_canonicalize_tool_result_content_warns_for_multiple_text_blocks() -> None: + logger = _LoggerSpy() + result = CallToolResult( + content=[ + TextContent(type="text", text="first"), + TextContent(type="text", text="second"), + ], + isError=False, + ) + setattr(result, "structuredContent", {"fresh": True}) + + canonicalize_tool_result_content_for_llm(result, logger=logger, source="test.helper") + + assert len(logger.warning_calls) == 1 + message, data = logger.warning_calls[0] + assert "structuredContent" in message + assert data == {"text_block_count": 2, "source": "test.helper"} + + +def test_tool_result_text_for_llm_uses_structured_content_json() -> None: + result = CallToolResult( + content=[TextContent(type="text", text="stale summary")], + isError=False, + ) + setattr(result, "structuredContent", {"b": 2, "a": 1}) + + text = tool_result_text_for_llm(result) + + assert text == '{"a":1,"b":2}' diff --git a/tests/unit/fast_agent/ui/test_shell_tool_result_display.py b/tests/unit/fast_agent/ui/test_shell_tool_result_display.py index 4b6e45bad..044222ad6 100644 --- a/tests/unit/fast_agent/ui/test_shell_tool_result_display.py +++ b/tests/unit/fast_agent/ui/test_shell_tool_result_display.py @@ -132,3 +132,33 @@ def test_tool_result_prefers_structured_content_over_many_text_blocks() -> None: assert '"id": "b"' in rendered assert "TextContent(" not in rendered assert "text only" in rendered + + +def test_tool_result_prefers_structured_content_when_text_blocks_disagree() -> None: + display = ConsoleDisplay() + result = CallToolResult( + content=[ + TextContent(type="text", text='{"id":"a","status":"closed"}'), + TextContent(type="text", text='{"id":"b","status":"pending"}'), + ], + isError=False, + ) + setattr( + result, + "structuredContent", + { + "result": [ + {"id": "a", "status": "open"}, + {"id": "b", "status": "escalated"}, + ] + }, + ) + + with console.console.capture() as capture: + display.show_tool_result(result, name="dev", tool_name="voice__crm_tickets") + + rendered = capture.get() + assert '"status": "open"' in rendered + assert '"status": "escalated"' in rendered + assert '"status":"closed"' not in rendered + assert '"status":"pending"' not in rendered From 165360cd8236366601e80a94fe64e0c8db8dc072 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:05:12 +0100 Subject: [PATCH 4/5] structured content mismatch notification --- src/fast_agent/ui/tool_display.py | 37 ++++++++++++++++++- .../ui/test_shell_tool_result_display.py | 2 + 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/fast_agent/ui/tool_display.py b/src/fast_agent/ui/tool_display.py index 85666d543..22e8897f0 100644 --- a/src/fast_agent/ui/tool_display.py +++ b/src/fast_agent/ui/tool_display.py @@ -705,10 +705,45 @@ def _build_tool_result_bottom_metadata( bottom_metadata_items.append(self._display._format_elapsed(timing_seconds)) if has_structured: - bottom_metadata_items.append("Structured ■") + structured_label = "Structured ■" + if self._has_structured_text_content_mismatch(result): + structured_label += " (TextContent mismatch)" + bottom_metadata_items.append(structured_label) return bottom_metadata_items or None + @staticmethod + def _has_structured_text_content_mismatch(result: "CallToolResult") -> bool: + from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content + + structured_content = getattr(result, "structuredContent", None) + content = getattr(result, "content", None) + if not ( + isinstance(structured_content, (dict, list)) + and isinstance(content, list) + and len(content) > 1 + and all(is_text_content(item) for item in content) + ): + return False + + parsed_blocks: list[object] = [] + for item in content: + text = get_text(item) + if text is None: + return False + try: + parsed_blocks.append(json.loads(text)) + except json.JSONDecodeError: + return False + + if structured_content == parsed_blocks: + return False + + if isinstance(structured_content, dict): + return all(value != parsed_blocks for value in structured_content.values()) + + return True + def _prepare_read_text_file_result_display( self, *, diff --git a/tests/unit/fast_agent/ui/test_shell_tool_result_display.py b/tests/unit/fast_agent/ui/test_shell_tool_result_display.py index ab12170d2..099c63035 100644 --- a/tests/unit/fast_agent/ui/test_shell_tool_result_display.py +++ b/tests/unit/fast_agent/ui/test_shell_tool_result_display.py @@ -132,6 +132,7 @@ def test_tool_result_prefers_structured_content_over_many_text_blocks() -> None: assert '"id": "b"' in rendered assert "TextContent(" not in rendered assert "text only" in rendered + assert "TextContent mismatch" not in rendered def test_tool_result_prefers_structured_content_when_text_blocks_disagree() -> None: @@ -162,6 +163,7 @@ def test_tool_result_prefers_structured_content_when_text_blocks_disagree() -> N assert '"status": "escalated"' in rendered assert '"status":"closed"' not in rendered assert '"status":"pending"' not in rendered + assert "Structured ■ (TextContent mismatch)" in rendered def test_structured_tool_result_shows_transport_timing_and_structured_footer() -> None: From a63f2654d29a9b945337828c120e85beef64187b Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:10:47 +0100 Subject: [PATCH 5/5] fix: preserve copied tool result content list --- src/fast_agent/mcp/helpers/content_helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fast_agent/mcp/helpers/content_helpers.py b/src/fast_agent/mcp/helpers/content_helpers.py index 16d2d35ce..57224b150 100644 --- a/src/fast_agent/mcp/helpers/content_helpers.py +++ b/src/fast_agent/mcp/helpers/content_helpers.py @@ -151,7 +151,9 @@ def canonicalize_tool_result_content_for_llm( """ raw_content = getattr(result, "content", None) - content = cast("list[ContentBlock]", raw_content) if isinstance(raw_content, list) else [] + content = ( + cast("list[ContentBlock]", list(raw_content)) if isinstance(raw_content, list) else [] + ) structured_content = getattr(result, "structuredContent", None) if structured_content is None: