Skip to content

fix: allow messages with tool blocks when tools array is omitted#239

Open
rwestergren wants to merge 1 commit into
aws-samples:mainfrom
rwestergren:fix-toolconfig-replayed-tool-blocks
Open

fix: allow messages with tool blocks when tools array is omitted#239
rwestergren wants to merge 1 commit into
aws-samples:mainfrom
rwestergren:fix-toolconfig-replayed-tool-blocks

Conversation

@rwestergren
Copy link
Copy Markdown

@rwestergren rwestergren commented Apr 20, 2026

Issue #, if available:

N/A — filed directly as a PR per guidance. Happy to split into an issue + PR if the maintainers prefer.

Description of changes:

Problem

Bedrock rejects requests with:

ValidationException: The toolConfig field must be defined when using the toolUse and toolResult content blocks

when messages contain toolUse/toolResult blocks but no tools array is provided. Common during conversation compaction/summarization flows where prior tool-calling turns are replayed as context but tools aren't needed for the response.

Fix

In _parse_request, when chat_request.tools is empty but messages contain tool blocks, synthesize minimal placeholder toolSpec entries from toolUse names already in history. Falls back to a single generic placeholder if only toolResult blocks exist. Logs a warning when the fallback activates.

Mirrors the documented workaround in strands-agents/sdk-python#998. Same pattern reported in opencode#10259, langchain-aws#591, and others.

No behavior change for existing working requests.

Validated in production against OpenCode compaction with Claude Opus 4.7.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Bedrock requires toolConfig whenever messages contain toolUse or toolResult blocks, even when the caller does not supply `tools`. This affects conversation compaction/summarization flows where prior tool-calling turns are replayed as context but no tools are needed for the response.

When `tools` is empty but tool blocks exist in history, synthesize minimal placeholder toolSpec entries from the toolUse names already present so Bedrock accepts the request. Log a warning so the fallback path is observable.
Copy link
Copy Markdown
Member

@zxkane zxkane left a comment

Choose a reason for hiding this comment

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

Recommend switching to the strip-to-text approach from langchain-aws

Thanks for the thorough writeup — the bug is real and the references are spot-on. However, after comparing the three upstream fixes you cited, I'd like to request changes and suggest a different strategy before this lands.

Survey of the three upstreams

Project Strategy Opt-in Notes
strands-agents/sdk-python #1003 Single static noop tool + system-prompt nudges No Only applied inside SummarizingConversationManager's own internal call — SDK knows it's doing summarization. Not comparable to a generic gateway.
anomalyco/opencode #8497#8658 Single static _noop tool, excluded from activeTools Yes (rolled back from always-on to litellmProxy flag) Maintainers walked back unconditional injection.
langchain-ai/langchain-aws #595 Strip toolUse/toolResult blocks to text, keep toolConfig=None No Preserves the "no tools declared" contract truthfully.

Why I think langchain-aws's approach is the right fit here

This project is an OpenAI-compatible gateway, not an SDK that knows the caller's intent. The current PR reacts to "client didn't send tools" by fabricating a toolConfig the client never declared. A few concrete problems with that direction:

  1. The placeholder can be invoked by the model. With additionalProperties: True and a generic description, nothing prevents the model from generating a toolUse for the fabricated tool. The gateway will faithfully forward that tool_calls response to the client, which has no handler for a tool it never registered. strands works around this with system-prompt nudges; opencode by excluding from activeTools. Neither mechanism exists cleanly at the Bedrock Converse API level for a generic gateway.

  2. placeholder_tool is engineered to fail. When only toolResult blocks remain (truncated history), the generic placeholder's name is guaranteed not to match any toolResult.toolUseId's originating tool. Bedrock will still reject the request — just with a more confusing error that doesn't point back at this gateway.

  3. Per-name reconstruction is unnecessary complexity. strands and opencode both ship a single static placeholder. The empirical answer is that Bedrock cares about toolConfig existing, not about matching names from history. _reconstruct_tools_from_messages and the dedup logic can all be removed.

  4. Dishonest contract. The client said "I have no tools." A gateway faithfully translating that should send toolConfig=None downstream, not invent a schema. If Bedrock refuses messages with replayed tool blocks when toolConfig=None, the honest fix is to erase the tool blocks (langchain-aws) rather than manufacture a schema.

Concrete asks

  1. Switch to strip-to-text, mirroring langchain-aws PR #595:

    • Add a _convert_tool_blocks_to_text(messages) helper that rewrites toolUse"[Called {name} with parameters: {json}]" and toolResult"[Tool result: ...]".
    • In _parse_request, when chat_request.tools is empty and _messages_contain_tool_blocks(messages) is true, rewrite the messages instead of synthesizing toolConfig.
    • This eliminates the model-misinvocation risk, eliminates the placeholder_tool-won't-match-toolUseId failure mode, and removes ~60 lines of reconstruction code.
  2. Consider an opt-in env flag (STRIP_ORPHAN_TOOL_BLOCKS, default true is fine — the conversion is safe) so operators who prefer the client-broken-surface-a-400 behavior can disable it. opencode's maintainers landed on an opt-in after trying always-on; worth learning from.

  3. Log once per request, not twice. The current logger.warning fires on every compaction request — which is the intended happy path for this fix. Downgrade to logger.info gated by DEBUG, matching the convention already used at bedrock.py:762/799/844/848. Optionally pair with warnings.warn(..., RuntimeWarning) the way langchain-aws does, so library users notice.

  4. Sanitize user-visible strings. If you keep any form of the synthesized-tool approach as a fallback, the description field ("(reconstructed placeholder for prior tool use: X)") and the name "placeholder_tool" are sent to Bedrock and may surface in traces or model context. Rewrite as neutral production copy.

Reference: langchain-aws's helpers (abbreviated)

def _has_tool_use_or_result_blocks(messages):
    for message in messages:
        for block in message.get("content", []):
            if "toolUse" in block or "toolResult" in block:
                return True
    return False

def _convert_tool_blocks_to_text(messages):
    # toolUse  -> "[Called {name} with parameters: {json.dumps(input)}]"
    # toolResult -> "[Tool result: ...]"
    ...

See the full implementation at libs/aws/langchain_aws/chat_models/bedrock_converse.py in PR #595.

Summary

The problem you identified is real, but I think the solution should be "faithfully remove what the client said it didn't have" rather than "fabricate what the client didn't declare." Happy to discuss if you see a reason the strip-to-text approach doesn't work for this codebase.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants