fix: allow messages with tool blocks when tools array is omitted#239
fix: allow messages with tool blocks when tools array is omitted#239rwestergren wants to merge 1 commit into
Conversation
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.
zxkane
left a comment
There was a problem hiding this comment.
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:
-
The placeholder can be invoked by the model. With
additionalProperties: Trueand a generic description, nothing prevents the model from generating atoolUsefor the fabricated tool. The gateway will faithfully forward thattool_callsresponse to the client, which has no handler for a tool it never registered. strands works around this with system-prompt nudges; opencode by excluding fromactiveTools. Neither mechanism exists cleanly at the Bedrock Converse API level for a generic gateway. -
placeholder_toolis engineered to fail. When onlytoolResultblocks remain (truncated history), the generic placeholder'snameis guaranteed not to match anytoolResult.toolUseId's originating tool. Bedrock will still reject the request — just with a more confusing error that doesn't point back at this gateway. -
Per-name reconstruction is unnecessary complexity. strands and opencode both ship a single static placeholder. The empirical answer is that Bedrock cares about
toolConfigexisting, not about matching names from history._reconstruct_tools_from_messagesand the dedup logic can all be removed. -
Dishonest contract. The client said "I have no tools." A gateway faithfully translating that should send
toolConfig=Nonedownstream, not invent a schema. If Bedrock refuses messages with replayed tool blocks whentoolConfig=None, the honest fix is to erase the tool blocks (langchain-aws) rather than manufacture a schema.
Concrete asks
-
Switch to
strip-to-text, mirroring langchain-aws PR #595:- Add a
_convert_tool_blocks_to_text(messages)helper that rewritestoolUse→"[Called {name} with parameters: {json}]"andtoolResult→"[Tool result: ...]". - In
_parse_request, whenchat_request.toolsis empty and_messages_contain_tool_blocks(messages)is true, rewrite the messages instead of synthesizingtoolConfig. - This eliminates the model-misinvocation risk, eliminates the
placeholder_tool-won't-match-toolUseId failure mode, and removes ~60 lines of reconstruction code.
- Add a
-
Consider an opt-in env flag (
STRIP_ORPHAN_TOOL_BLOCKS, defaulttrueis 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. -
Log once per request, not twice. The current
logger.warningfires on every compaction request — which is the intended happy path for this fix. Downgrade tologger.infogated byDEBUG, matching the convention already used atbedrock.py:762/799/844/848. Optionally pair withwarnings.warn(..., RuntimeWarning)the way langchain-aws does, so library users notice. -
Sanitize user-visible strings. If you keep any form of the synthesized-tool approach as a fallback, the
descriptionfield ("(reconstructed placeholder for prior tool use: X)") and thename"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.
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:
when
messagescontaintoolUse/toolResultblocks but notoolsarray 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, whenchat_request.toolsis empty but messages contain tool blocks, synthesize minimal placeholdertoolSpecentries fromtoolUsenames already in history. Falls back to a single generic placeholder if onlytoolResultblocks 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.