Skip to content

Commit 7fb50d5

Browse files
authored
fix(aws): convert string tool_use input to dict in _lc_content_to_bedrock (#880)
Fixes #827. When streaming tool-use responses, Bedrock's Converse API sends tool input as incremental JSON string fragments across `contentBlockDelta` events. LangChain accumulates these into a JSON string on `content[].tool_use.input`. If this message is sent back to Bedrock, `_lc_content_to_bedrock` passes the string directly to the Bedrock API, which expects a dict, causing a `ValidationException`. PR #843 attempted to fix this by parsing input inside `_bedrock_to_lc`, but this broke streaming: `tool_call_chunks[].args` must be string, and parsing to dict at that level violated this requirement, resulting in Pydantic validation failures and chunk merging type mismatches. This fix in this PR instead parses string input to dict in `_lc_content_to_bedrock`, leaving the initial Bedrock->LC streaming accumulation and chunk merging untouched.
1 parent 1b25464 commit 7fb50d5

File tree

3 files changed

+254
-2
lines changed

3 files changed

+254
-2
lines changed

libs/aws/langchain_aws/chat_models/bedrock_converse.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
convert_to_openai_function,
5959
convert_to_openai_tool,
6060
)
61+
from langchain_core.utils.json import parse_partial_json
6162
from langchain_core.utils.pydantic import TypeBaseModel, is_basemodel_subclass
6263
from langchain_core.utils.utils import _build_model_kwargs
6364
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
@@ -2020,22 +2021,28 @@ def _lc_content_to_bedrock(
20202021
# Assume block in bedrock document format
20212022
bedrock_content.append({"document": block["document"]})
20222023
elif block["type"] == "tool_use":
2024+
tool_input = block["input"]
2025+
if isinstance(tool_input, str):
2026+
tool_input = parse_partial_json(tool_input) if tool_input else {}
20232027
bedrock_content.append(
20242028
{
20252029
"toolUse": {
20262030
"toolUseId": block["id"],
2027-
"input": block["input"],
2031+
"input": tool_input,
20282032
"name": block["name"],
20292033
}
20302034
}
20312035
)
20322036
elif block["type"] == "server_tool_use":
20332037
# System tools use toolUse format (same as regular tools)
2038+
tool_input = block["input"]
2039+
if isinstance(tool_input, str):
2040+
tool_input = parse_partial_json(tool_input) if tool_input else {}
20342041
bedrock_content.append(
20352042
{
20362043
"toolUse": {
20372044
"toolUseId": block["id"],
2038-
"input": block["input"],
2045+
"input": tool_input,
20392046
"name": block["name"],
20402047
}
20412048
}

libs/aws/tests/integration_tests/chat_models/test_bedrock_converse.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,63 @@ def get_weather(location: str) -> str:
587587
assert isinstance(response, AIMessage)
588588

589589

590+
def test_streaming_tool_use_round_trip() -> None:
591+
"""Test that streaming tool call messages can be sent back to Bedrock.
592+
593+
Regression test for https://github.com/langchain-ai/langchain-aws/issues/827.
594+
After streaming, content[].tool_use.input is a JSON string instead of a
595+
dict. When a message is reconstructed from content alone (e.g., loaded
596+
from a checkpoint without tool_calls), _lc_content_to_bedrock must parse
597+
string input to a dict to avoid Bedrock ValidationException.
598+
"""
599+
600+
@tool
601+
def get_weather(city: str) -> str:
602+
"""Get the current weather for a city."""
603+
return "It's sunny and 72F."
604+
605+
llm = ChatBedrockConverse(
606+
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
607+
)
608+
llm_with_tools = llm.bind_tools([get_weather], tool_choice="any")
609+
610+
input_message = HumanMessage("What is the weather in Paris?")
611+
612+
full: Optional[BaseMessageChunk] = None
613+
for chunk in llm_with_tools.stream([input_message]):
614+
assert isinstance(chunk, AIMessageChunk)
615+
full = chunk if full is None else full + chunk
616+
assert isinstance(full, AIMessageChunk)
617+
618+
for tc_chunk in full.tool_call_chunks:
619+
assert tc_chunk["args"] is None or isinstance(tc_chunk["args"], str)
620+
621+
assert len(full.tool_calls) == 1
622+
tool_call = full.tool_calls[0]
623+
assert tool_call["name"] == "get_weather"
624+
assert isinstance(tool_call["args"], dict)
625+
assert isinstance(full.content, list)
626+
tool_block = next(
627+
b for b in full.content if isinstance(b, dict) and b.get("type") == "tool_use"
628+
)
629+
assert isinstance(tool_block["input"], str), (
630+
"After streaming accumulation, content[].tool_use.input should be a "
631+
"string. If this assertion fails, the streaming behavior has changed "
632+
"and this test may need updating."
633+
)
634+
635+
restored_msg = AIMessage(content=full.content)
636+
assert restored_msg.tool_calls == []
637+
638+
tool_result = ToolMessage(
639+
content=get_weather.invoke(tool_call).content,
640+
tool_call_id=tool_call["id"],
641+
)
642+
643+
response = llm_with_tools.invoke([input_message, restored_msg, tool_result])
644+
assert isinstance(response, AIMessage)
645+
646+
590647
@pytest.mark.default_cassette("test_thinking.yaml.gz")
591648
@pytest.mark.vcr
592649
@pytest.mark.parametrize("output_version", ["v0", "v1"])

libs/aws/tests/unit_tests/chat_models/test_bedrock_converse.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from langchain_core.language_models import BaseChatModel
1010
from langchain_core.messages import (
1111
AIMessage,
12+
AIMessageChunk,
1213
BaseMessage,
1314
HumanMessage,
1415
SystemMessage,
@@ -30,6 +31,7 @@
3031
_has_tool_use_or_result_blocks,
3132
_lc_content_to_bedrock,
3233
_messages_to_bedrock,
34+
_parse_stream_event,
3335
_snake_to_camel,
3436
_snake_to_camel_keys,
3537
)
@@ -3528,3 +3530,189 @@ def test_ls_invocation_params_prefers_explicit_region_over_inferred(
35283530
invocation_params = ls_params["ls_invocation_params"] # type: ignore[typeddict-item]
35293531
# Explicit region_name should be used, not the one from client
35303532
assert invocation_params["region_name"] == "us-west-2"
3533+
3534+
3535+
def test__lc_content_to_bedrock_tool_use_dict_input_unchanged() -> None:
3536+
"""Dict input should pass through unchanged."""
3537+
content: List[Union[str, Dict[str, Any]]] = [
3538+
{
3539+
"type": "tool_use",
3540+
"id": "tool_1",
3541+
"name": "get_weather",
3542+
"input": {"city": "Paris"},
3543+
}
3544+
]
3545+
result = _lc_content_to_bedrock(content)
3546+
assert result == [
3547+
{
3548+
"toolUse": {
3549+
"toolUseId": "tool_1",
3550+
"name": "get_weather",
3551+
"input": {"city": "Paris"},
3552+
}
3553+
}
3554+
]
3555+
3556+
3557+
def test__lc_content_to_bedrock_tool_use_string_input_parsed_to_dict() -> None:
3558+
"""String input (from streaming accumulation) should be parsed to dict."""
3559+
content: List[Union[str, Dict[str, Any]]] = [
3560+
{
3561+
"type": "tool_use",
3562+
"id": "tool_1",
3563+
"name": "get_weather",
3564+
"input": '{"city": "Paris"}',
3565+
}
3566+
]
3567+
result = _lc_content_to_bedrock(content)
3568+
assert result == [
3569+
{
3570+
"toolUse": {
3571+
"toolUseId": "tool_1",
3572+
"name": "get_weather",
3573+
"input": {"city": "Paris"},
3574+
}
3575+
}
3576+
]
3577+
3578+
3579+
def test__lc_content_to_bedrock_tool_use_empty_string_input() -> None:
3580+
"""Empty string input should become empty dict."""
3581+
content: List[Union[str, Dict[str, Any]]] = [
3582+
{
3583+
"type": "tool_use",
3584+
"id": "tool_1",
3585+
"name": "no_args_tool",
3586+
"input": "",
3587+
}
3588+
]
3589+
result = _lc_content_to_bedrock(content)
3590+
assert result == [
3591+
{
3592+
"toolUse": {
3593+
"toolUseId": "tool_1",
3594+
"name": "no_args_tool",
3595+
"input": {},
3596+
}
3597+
}
3598+
]
3599+
3600+
3601+
def test__lc_content_to_bedrock_server_tool_use_string_input_parsed() -> None:
3602+
"""String input on server_tool_use should also be parsed to dict."""
3603+
content: List[Union[str, Dict[str, Any]]] = [
3604+
{
3605+
"type": "server_tool_use",
3606+
"id": "tool_1",
3607+
"name": "grounding",
3608+
"input": '{"query": "latest news"}',
3609+
}
3610+
]
3611+
result = _lc_content_to_bedrock(content)
3612+
assert result == [
3613+
{
3614+
"toolUse": {
3615+
"toolUseId": "tool_1",
3616+
"name": "grounding",
3617+
"input": {"query": "latest news"},
3618+
}
3619+
}
3620+
]
3621+
3622+
3623+
def test_content_block_start_tool_call_chunk_args_type() -> None:
3624+
"""contentBlockStart should produce tool_call_chunk with string/None args."""
3625+
event = {
3626+
"contentBlockStart": {
3627+
"contentBlockIndex": 0,
3628+
"start": {
3629+
"toolUse": {
3630+
"toolUseId": "tool_1",
3631+
"name": "get_weather",
3632+
}
3633+
},
3634+
}
3635+
}
3636+
chunk = _parse_stream_event(event)
3637+
assert isinstance(chunk, AIMessageChunk)
3638+
assert len(chunk.tool_call_chunks) == 1
3639+
args = chunk.tool_call_chunks[0]["args"]
3640+
assert args is None or isinstance(args, str)
3641+
3642+
3643+
def test_content_block_delta_tool_call_chunk_args_type() -> None:
3644+
"""contentBlockDelta should produce tool_call_chunk with string args."""
3645+
event = {
3646+
"contentBlockDelta": {
3647+
"contentBlockIndex": 0,
3648+
"delta": {
3649+
"toolUse": {
3650+
"input": '{"city": "Paris"}',
3651+
}
3652+
},
3653+
}
3654+
}
3655+
chunk = _parse_stream_event(event)
3656+
assert isinstance(chunk, AIMessageChunk)
3657+
assert len(chunk.tool_call_chunks) == 1
3658+
args = chunk.tool_call_chunks[0]["args"]
3659+
assert isinstance(args, str)
3660+
assert args == '{"city": "Paris"}'
3661+
3662+
3663+
def test_streaming_tool_use_round_trip() -> None:
3664+
"""Simulate streaming tool call, accumulate chunks, convert back to Bedrock."""
3665+
events = [
3666+
{
3667+
"contentBlockStart": {
3668+
"contentBlockIndex": 0,
3669+
"start": {
3670+
"toolUse": {
3671+
"toolUseId": "tool_abc",
3672+
"name": "get_weather",
3673+
}
3674+
},
3675+
}
3676+
},
3677+
{
3678+
"contentBlockDelta": {
3679+
"contentBlockIndex": 0,
3680+
"delta": {
3681+
"toolUse": {
3682+
"input": '{"city":',
3683+
}
3684+
},
3685+
}
3686+
},
3687+
{
3688+
"contentBlockDelta": {
3689+
"contentBlockIndex": 0,
3690+
"delta": {
3691+
"toolUse": {
3692+
"input": ' "Paris"}',
3693+
}
3694+
},
3695+
}
3696+
},
3697+
]
3698+
3699+
full = None
3700+
for event in events:
3701+
chunk = _parse_stream_event(event)
3702+
if chunk is not None:
3703+
full = chunk if full is None else full + chunk
3704+
3705+
assert isinstance(full, AIMessageChunk)
3706+
assert isinstance(full.content, list)
3707+
tool_block = next(
3708+
b for b in full.content if isinstance(b, dict) and b.get("type") == "tool_use"
3709+
)
3710+
assert isinstance(tool_block["input"], str)
3711+
assert len(full.tool_call_chunks) > 0
3712+
3713+
bedrock_content = _lc_content_to_bedrock(
3714+
cast(List[Union[str, Dict[str, Any]]], full.content)
3715+
)
3716+
tool_use_block = bedrock_content[0]["toolUse"]
3717+
assert isinstance(tool_use_block["input"], dict)
3718+
assert tool_use_block["input"] == {"city": "Paris"}

0 commit comments

Comments
 (0)