Skip to content

Python: Fix BedrockChatClient sending invalid toolChoice "none" to Bedrock API#4535

Open
LEDazzio01 wants to merge 1 commit intomicrosoft:mainfrom
LEDazzio01:fix/4529-bedrock-tool-choice-none
Open

Python: Fix BedrockChatClient sending invalid toolChoice "none" to Bedrock API#4535
LEDazzio01 wants to merge 1 commit intomicrosoft:mainfrom
LEDazzio01:fix/4529-bedrock-tool-choice-none

Conversation

@LEDazzio01
Copy link
Contributor

Motivation and Context

When FunctionInvocationLayer exhausts its max iterations or hits the error threshold, it sets tool_choice="none" for a final "wrap-up" LLM call (to get a plain-text summary). BedrockChatClient._prepare_options() then maps this to toolConfig.toolChoice: {"none": {}}, but the AWS Bedrock Converse / ConverseStream API only accepts auto, any, or tool as valid toolChoice keys — causing a botocore.exceptions.ParamValidationError.

Fixes #4529

Description

Split the case "auto" | "none" match branch in _prepare_options:

  • "none": Set tool_config = None so toolConfig is omitted entirely from the Bedrock request. This matches the semantic intent ("don't use tools on this call") and is consistent with how the OpenAI client handles tool_choice="none".
  • "auto": Unchanged — emits {"auto": {}} as before.

Also added tool_config = tool_config or {} inside each individual branch ("auto", "required") instead of before the match statement, so "none" can cleanly set tool_config = None without it being overwritten.

Tests

Added 3 new test cases:

Test Verifies
test_prepare_options_tool_choice_none_omits_tool_config toolConfig absent from request when tool_choice="none" (even with tools provided)
test_prepare_options_tool_choice_auto_includes_tool_config toolChoice: {"auto": {}} present when tool_choice="auto"
test_prepare_options_tool_choice_required_includes_any toolChoice: {"any": {}} present when tool_choice="required"

Contribution Checklist

  • The code builds clean without any errors or warnings
  • The PR follows the Contribution Guidelines
  • All unit tests pass, and I have added new tests where possible
  • Is this a breaking change? If yes, add "[BREAKING]" prefix to the title of the PR.

Bedrock's Converse API only accepts "auto", "any", or "tool" as valid
toolChoice keys. The previous code mapped tool_choice="none" to
{"none": {}}, which causes a botocore.exceptions.ParamValidationError.

When tool_choice="none" (set by FunctionInvocationLayer after exhausting
max iterations), the fix now omits toolConfig entirely so the model
won't attempt tool calls.

Added tests for tool_choice="none", "auto", and "required" modes.

Fixes microsoft#4529
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes Bedrock Converse request generation so tool_choice="none" no longer produces an invalid toolConfig.toolChoice: {"none": {}} payload that the Bedrock API rejects, and adds unit coverage for the supported tool-choice modes.

Changes:

  • Update BedrockChatClient._prepare_options() to omit toolConfig entirely when tool_choice="none", while keeping auto/required behavior intact.
  • Add unit tests validating toolConfig omission/inclusion for none/auto/required.
  • Refactor Bedrock client invocation/response parsing in a few areas (e.g., _invoke_converse, more defensive parsing, and typing adjustments).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
python/packages/bedrock/agent_framework_bedrock/_chat_client.py Fixes Bedrock toolChoice mapping for "none" and adjusts request/response handling.
python/packages/bedrock/tests/test_bedrock_client.py Adds regression tests for tool choice behavior in _prepare_options.

@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.

# type: ignore
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The header # type: ignore does not disable type checking for the module in mypy/pyright (mypy uses # mypy: ignore-errors, pyright uses # pyright: ignore). As written, this comment is misleading and may not achieve the intended suppression; consider removing it or switching to the correct per-tool directive / targeted ignores.

Suggested change
# type: ignore
# mypy: ignore-errors
# pyright: ignore

Copilot uses AI. Check for mistakes.
Comment on lines +351 to 360
raw_finish_reason = (
parsed_response.finish_reason if isinstance(parsed_response.finish_reason, str) else None
)
finish_reason = self._map_finish_reason(raw_finish_reason)
yield ChatResponseUpdate(
response_id=parsed_response.response_id,
contents=contents,
model_id=parsed_response.model_id,
finish_reason=parsed_response.finish_reason,
finish_reason=finish_reason,
raw_representation=parsed_response.raw_representation,
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

In the streaming path, parsed_response.finish_reason is already normalized by _process_converse_response (via _map_finish_reason). Mapping it again with _map_finish_reason turns values like "stop" into None, so streamed ChatResponseUpdate.finish_reason will be incorrect/missing. Use parsed_response.finish_reason directly (or capture the raw Bedrock completion reason before mapping).

Copilot uses AI. Check for mistakes.
Comment on lines +295 to 298
if client:
self._bedrock_client = client
else:
session = boto3_session or self._create_session(settings)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

if client: relies on truthiness; a valid injected client/stub that defines __bool__/__len__ as falsey would be ignored and a boto3 client would be created instead. Prefer an explicit if client is not None: check (consistent with other modules in this repo).

Suggested change
if client:
self._bedrock_client = client
else:
session = boto3_session or self._create_session(settings)
if client is not None:
self._bedrock_client = client
else:
if boto3_session is not None:
session = boto3_session
else:
session = self._create_session(settings)

Copilot uses AI. Check for mistakes.
@LEDazzio01
Copy link
Contributor Author

@copilot Thanks for the review! All 3 comments reference pre-existing code from main — none are changes introduced by this PR. The only intentional change is the tool_choice match branch split in _prepare_options:

  1. # type: ignore header — This line exists in the current main branch as-is. Changing type-checking directives is out of scope for this bug fix.
  2. Double _map_finish_reason in streaming path — The streaming _stream() logic is unchanged from main. The finish_reason handling there is pre-existing.
  3. if client: vs if client is not None: — Also unchanged from main. The __init__ constructor logic is pre-existing.

Happy to open a separate cleanup PR for these if desired, but keeping this PR focused on the #4529 fix.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: BedrockChatClient sends invalid toolChoice: {"none": {}} — Bedrock rejects it

3 participants