Skip to content

Python: fix(core): treat a single mapping tool as one tool in merge_chat_options - #8732

Open
Anish Mehta (anishmehta24) wants to merge 3 commits into
microsoft:mainfrom
anishmehta24:fix/merge-chat-options-single-dict-tool
Open

Anish Mehta (anishmehta24) wants to merge 3 commits into
microsoft:mainfrom
anishmehta24:fix/merge-chat-options-single-dict-tool

Conversation

@anishmehta24

Copy link
Copy Markdown
Contributor

Motivation & Context

merge_chat_options merges tools with for tool in value if isinstance(value, Iterable) else [value]. A single tool given as a mapping, like a hosted tool {"type": "web_search"}, is iterable, so it gets spread into its keys:

merge_chat_options({"tools": [f]}, {"tools": {"type": "web_search", "name": "ws"}})["tools"]
# [f, 'type', 'name']      expected [f, {'type': 'web_search', 'name': 'ws'}]

merge_chat_options({"tools": {"type": "web_search"}}, {"tools": [f]})["tools"]
# ['type', f]              expected [{'type': 'web_search'}, f]

normalize_tools already treats a mapping as one tool; the merge didn't.

Description & Review Guide

  • What are the major changes? A small _as_tool_list helper that wraps a mapping (or any non-iterable) in a list, used for both the base and override tools in the merge.
  • What is the impact of these changes? Merging agent-level and run-level options keeps a single dict tool intact.
  • What do you want reviewers to focus on? Only the merge path changes; when there are no base tools the override is still returned as given.

Related Issue

No existing issue.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible

Test: test_merge_chat_options_single_mapping_tool_is_not_spread_into_keys in tests/core/test_types.py, both directions; it fails on main. pytest packages/core/tests/core/test_types.py passes, and ruff and the repo's pre-commit checks are clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Sep 24, 2026

def _as_tool_list(tools: Any) -> list[Any]:
"""Tools as a list, treating a single mapping (e.g. {"type": "web_search"}) as one tool."""
if isinstance(tools, Mapping) or not isinstance(tools, Iterable):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Anish Mehta (@anishmehta24) I would rather keep all tool-shape normalization inside normalize_tools instead of adding a second parsing path in merge_chat_options. _as_tool_list already diverges from normalize_tools for supported provider-native specs such as Pydantic BaseModel tools, which are iterable and get expanded into field/value tuples here. Could we route both sides through the existing normalization logic and add coverage for merging a list with one Pydantic provider tool?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped _as_tool_list and both sides of the merge now go through normalize_tools, so there's only one place that decides tool shapes. Added a test merging a Pydantic provider tool, inside a list and on its own, on either side. One thing to flag: normalize_tools wraps plain callables in a new FunctionTool each time, so the same undecorated function passed on both sides won't dedupe anymore. @tool-decorated ones still do. Happy to handle that if you think it matters.

@eavanvalkenburg

Copy link
Copy Markdown
Member

/review

# Add tools that aren't already present
merged_tools = list(base_tools)
for tool in value if isinstance(value, Iterable) else [value]: # type: ignore[reportUnknownVariableType]
merged_tools = normalize_tools(base_tools)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Anish Mehta (@anishmehta24) The callable dedupe regression you noted is material here. normalize_tools(base_tools) and normalize_tools(value) wrap the same undecorated function into two distinct FunctionTool objects, so tool not in merged_tools keeps both and providers can receive duplicate tool names. Please preserve identity across the merge or dedupe normalized tools by the repository’s tool-name rules, with a regression using the same plain callable on both sides.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed, merged tools are now deduped by tool name, so the same plain function on both sides ends up once. Added a regression for it.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MAF Automated Review — Iteration 1

Result: No findings
Scope: full PR (2 commit(s)): 94758a589908, 810bed732feb
Model: gpt-5.6-sol-fast

Overview

This PR routes both sides of a populated tools merge through the canonical normalize_tools path, preventing mapping and Pydantic provider-tool specifications from being expanded into their iterable contents. New tests cover both merge directions and wrapped or unwrapped provider tools, while the normal Agent execution path independently enforces tool-name uniqueness before provider dispatch. No new Critical, High, or Medium defect remains after deduplication against the supplied unresolved feedback.

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
No publishable findings remained after source verification for this scope.

names = {name for t in merged_tools if (name := _get_tool_name(t))}
for tool in normalize_tools(value):
name = _get_tool_name(tool)
if tool in merged_tools or (name is not None and name in names):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Anish Mehta (@anishmehta24) This name-only dedupe also drops a distinct override tool that happens to share the base tool’s name, silently keeping the base implementation, schema, and approval settings instead. Elsewhere _append_unique_tools treats distinct same-name tools as an error. Could this only collapse the same original callable/tool case, or otherwise preserve the duplicate-name error?

This branch was successfully deployed

1 active deployment
github-app-auth — 57e9025a Deployed Sep 29, 2026 by anishmehta24 via add_label #23947
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants