Skip to content

Python: fix(tools): answer a function call whose arguments are not a JSON object - #9573

Open
H23 (Madin-H23) wants to merge 4 commits into
microsoft:mainfrom
Madin-H23:fix/function-call-non-object-arguments
Open

H23 (Madin-H23) wants to merge 4 commits into
microsoft:mainfrom
Madin-H23:fix/function-call-non-object-arguments

Conversation

@Madin-H23

@Madin-H23 H23 (Madin-H23) commented Oct 21, 2026 •

Copy link
Copy Markdown

Motivation & Context

A model that answers a function call with something that is not a JSON object has not sent arguments, and the tool should not run as if it had.

Content.parse_arguments() wraps such a payload under a single "raw" key:

Content.from_function_call("call-1", "list_accounts", arguments="not {json").parse_arguments()
# {'raw': 'not {json'}

That is the right answer for a caller that wants to see what the model sent. The function-calling loop, however, used the result as the arguments of the call. For a tool whose schema has no required properties and does not set additionalProperties: false, the loop's own checks then pass — raw is just an extra key, and extra keys are allowed — so the tool was invoked with raw=... as if the model had made a well-formed call:

tool called with {'raw': 'not {json'}

A missing required argument or a wrong type is answered with an error the model can correct on; a malformed payload was not reported anywhere. When the tool is proxied to an MCP server, the malformed call reaches the server carrying a bogus raw argument.

Description & Review Guide

  • What are the major changes?

    The function-calling loop now parses the payload strictly (_parse_function_call_arguments) and answers a call whose arguments are not a JSON object the way it already answers a missing required property: with an argument-validation error result. The cause ("arguments are not a JSON object") is carried in the result's exception field; with the default include_detailed_errors=False the model-visible message is Error: Argument parsing failed., and the cause is appended to that message only when detailed errors are enabled. That is the same convention the existing missing-required-property path uses.

    Content.parse_arguments() keeps its documented "raw" wrapping for direct callers, so nothing changes for them.

    A call whose arguments are not arguments is now answered in both places where it could otherwise have been acted on: the invocation path, and the path that builds an approval request. A human is therefore no longer asked to approve a call that cannot run.

  • What is the impact of these changes?

    The tool is no longer invoked with an argument it never declared, and the model is told what went wrong. A well-formed call is unaffected.

    One behaviour change is worth calling out: a payload that is not a JSON object is now rejected for every tool, including a tool whose schema takes no parameters. On main, such a call ("null", whitespace-only, or malformed text) reached a zero-parameter tool with the extra raw key dropped by its input model and ran; it is now answered with the argument-validation error. Empty arguments ("") and a well-formed object are unaffected.

  • What do you want reviewers to focus on?

    Where the strictness lives. The check is in the loop rather than in parse_arguments() on purpose: parse_arguments() is a public API with a documented "raw" fallback, and the loop is the only caller that turns the result into a tool invocation, so that is the place where a payload that is not arguments has to be rejected.

    The error reuses the existing _FunctionArgumentValidationError path, so a malformed call produces the same kind of result as a missing required property, including the redacted message when argument snapshots are in play.

    The check is in the loop, so it covers the in-process FunctionTool invocation path. The MCP conversion in _mcp.py builds the outgoing tool_use input from Content.parse_arguments() and is not covered by this change; whether a malformed model-authored payload can reach that conversion is being checked separately.

Related Issue

Fixes #9562

The direction follows the expectation stated in the issue: a function call whose arguments are not a JSON object must be answered with an error instead of reaching the tool with an undeclared raw argument. The issue does not carry the ready-for-implementation label yet. We asked on the issue on 2026-10-22, and we will close this pull request if there is no confirmation by 2026-10-27.

AI Assistance

  • No material AI assistance was used.
  • This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.

AI assistance details: the issue analysis, the implementation in agent_framework/_tools.py, the regression tests, and this description were drafted with an AI coding agent (MiniMax M3.1 Flash via the ZCode CLI) under the repository's AI-Assisted Contributions Policy. What I verified myself: on the unmodified file the tool is invoked with {'raw': 'not {json'} (and with {'raw': [1, 2]} for a valid JSON array), which is the behaviour the issue reports; the new tests fail there and pass with the change, and a well-formed call still reaches the tool with its arguments unchanged; the full packages/core/tests suite, ruff check and ruff format --check with the pinned 0.17.0, and pyright on the changed file are unchanged apart from this fix.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

…JSON object

Closes microsoft#9562

Content.parse_arguments() wraps a payload that is not a JSON object under a
single "raw" key. That is the right answer for a caller that wants to see
what the model sent, but the function-calling loop used it as the arguments
of the call, so a model that answered with something that is not a JSON
object had its tool invoked with an argument the tool never declared, and
the model was never told that its call was malformed.

The loop now parses the payload strictly and answers a call whose arguments
are not a JSON object the way it already answers a missing required
property: with an argument-validation error result that names the cause, so
the model can retry. parse_arguments() keeps its documented behaviour for
direct callers.

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.

🟡 Changes recommended

Whitespace-only payloads still bypass validation and reach tools as bogus raw arguments.

1 open issue
What changed in this PR

Addresses #9562 by rejecting malformed function-call arguments in the execution loop while preserving the public Content.parse_arguments() behavior.

Changes:

  • Adds strict parsing through the existing argument-validation error path.
  • Adds regression tests for malformed payloads and valid object arguments.
File Description
python/​packages/​core/​tests/​core/​test_tools.py Tests malformed argument rejection and valid tool execution.
python/​packages/​core/​agent_framework/​_tools.py Validates argument payloads before invoking tools.

Review effort: Balanced

model can retry it.
"""
arguments = function_call.arguments
if isinstance(arguments, str) and arguments.strip():
The strict parse skipped a payload that was only whitespace, so such a call
still reached the tool as a bogus "raw" argument. A payload that is not empty
has to be a JSON object, and whitespace is not one.
@Madin-H23
H23 (Madin-H23) deployed to github-app-auth October 22, 2026 02:35 — with GitHub Actions Active
@Madin-H23

Copy link
Copy Markdown
Author

Good catch — the whitespace-only payload slipped through the strict parse, and the tool was still invoked with a bogus raw argument.

The parse skipped any payload whose stripped form was empty, so a call whose arguments were only whitespace took the lenient path. A payload that is not empty has to be a JSON object, and whitespace is not one, so the check now applies to every non-empty payload.

The malformed-argument test is parametrized over the shapes from the issue and now covers the whitespace-only payload as well. It fails against the first commit of this pull request — the tool is invoked with {'raw': ' '} — and passes against the current one.

packages/core/tests/core/test_tools.py passes in full (156 tests), the full packages/core/tests run has the same failures before and after the change (129 in the MCP and aiohttp areas that need optional dependencies this environment does not have), and ruff and pyright are clean on the changed file.

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.

Reviewed at 84de7d5 (merge base 08851e6).

What I ran

  • New tests on the PR head: 5 passed.
  • Same tests with _tools.py reverted to the merge base: all 4 malformed cases fail (tool gets called), the well-formed case passes. So the tests catch the bug.
  • tests/core/test_tools.py on head: 156 passed. tests/core on head: 6792 passed, 133 skipped.
  • ruff check and ruff format --check (0.17.0) on both changed files: clean.
  • A probe script through _try_execute_function_call_groups, head vs merge base, with inputs the tests don't cover.

The fix does what the issue asks. A tool with a permissive schema no longer runs with raw=..., and Content.parse_arguments() keeps its public fallback. One behaviour change for existing tools is worth a decision before merge.

1. Zero-parameter tools that used to run now fail on "null" and whitespace (worth considering)

_tools.py:2178-2183 rejects every non-empty string that does not decode to a dict. That also catches typed tools with no parameters. On the merge base they ran fine, because the extra raw key was dropped by the tool's input model:

@tool
def now() -> str: ...
arguments merge base PR head
"" runs runs
"{}" runs runs
"null" runs Error: Argument parsing failed.
" " runs Error: Argument parsing failed.
"not {json" runs Error: Argument parsing failed.

So "" means "no arguments" but " " and "null" are errors. That costs the model an extra round trip, and with max_iterations low it could end the run without the tool result. I have not confirmed that a provider sends "null" or whitespace for a no-arg call; that part is a suspicion. If you want to keep the strict check, one option is to treat null and whitespace-only payloads like "" (empty arguments) and reject only payloads that decode to something else or don't decode at all. Your call. It is the opposite of the earlier bot suggestion, so a maintainer may want to weigh in.

2. Approval-required tools still ask the human first (nit)

With approval_mode="always_require" and arguments="[1,2]", the loop still emits a function_approval_request carrying [1,2]. After the human approves, _auto_invoke_function returns Error: Argument parsing failed. (probe output in the evidence). Nothing unsafe happens, but the user approves a call that can never run. The check could also run where approval requests are built. A test for this path would help either way.

3. Malformed calls skip function middleware (nit, by design?)

The new check at _tools.py:2267 sits before the middleware branch at _tools.py:2304. Middleware that logs or audits tool calls no longer sees these calls. On the merge base it saw them with {"raw": ...}. Fine if intended. Worth one line in the docstring.

4. Description vs default config (nit)

The description says the error "names the cause, so the model can retry". With the default include_detailed_errors=False, the model gets only Error: Argument parsing failed.. The cause shows up only in exception or with detailed errors on. This matches the existing missing-required path, so the code is fine. The wording just oversells it a little.

Not checked

Real provider payloads (OpenAI, Azure, Ollama, vLLM), the streaming path end to end, the MCP path in _mcp.py:1529 (it still uses parse_arguments()), pyright/mypy, and anything outside packages/core.

@Madin-H23
H23 (Madin-H23) deployed to github-app-auth October 22, 2026 05:03 — with GitHub Actions Active
@Madin-H23

Copy link
Copy Markdown
Author

Thank you — this is the most useful review we have had on this pull request, and the first point in particular is a real finding. We verified each item against the code at 84de7d5 before answering.

1. Zero-parameter tools and "null" / whitespace payloads — confirmed, and we are keeping the strict rule, with the behaviour change now disclosed

Your table matches the code. The guard is _parse_function_call_arguments (python/packages/core/agent_framework/_tools.py): a non-empty string payload has to decode to a JSON object, otherwise the call is answered with an argument-validation error. So "" still means "no arguments" (the guard tests isinstance(arguments, str) and arguments, not a stripped copy), "{}" is an object, and "null", " " and "not {json" all fail the check. On the merge base a zero-parameter tool ran in those last three cases, because the extra raw key was dropped by the tool's input model — that is a behaviour change and we are not going to leave it undocumented.

We keep the strict rule for two reasons. First, it is what the issue asks for: "Arguments that are not a JSON object are invalid arguments", answered the way a missing required property is answered. Second, your own observation decides it: you did not find a provider that sends "null" or whitespace for a no-argument call, so this is a trade-off against a payload shape nobody has shown to occur. We would rather not narrow the contract for a hypothetical.

Your alternative is reasonable, and we would take it if a maintainer prefers it — under it, no payload can still reach a tool carrying an undeclared raw, because whitespace-only and "null" become {} and everything that decodes to a non-object or fails to decode is still rejected. The difference is only whether a malformed-but-empty payload is an error or an empty argument set. Since this is the opposite of the earlier bot suggestion, we would rather a maintainer weigh in than decide it here.

2. The approval path — agreed, this is a gap and we will fix it

Confirmed: the check runs inside _auto_invoke_function, after the approval request has already been created, so a human can be asked to approve a call that will then fail with "Argument parsing failed". Nothing unsafe happens — the call never reaches the tool — but the human is asked for a decision that cannot succeed. We will apply the same check where the approval request is built, so the model gets the error before anyone is prompted, and add a test for that path.

3. Middleware no longer sees malformed calls — intended, and we will say so in the code

Confirmed: the check sits before the middleware branch, so a logging or auditing middleware no longer records a malformed payload as a tool call. That is deliberate — a payload that is not arguments is not a tool call, and we do not want it counted as one. We will put that in the docstring so the next reader does not have to rediscover it.

4. The description oversells the error — agreed, fixing the wording

You are right. With the default include_detailed_errors=False, the model-visible result is Error: Argument parsing failed.; the cause ("arguments are not a JSON object") is in the exception field, and it is appended to the message only when detailed errors are enabled. That is the same convention the existing missing-required-property path uses, so the code is right and the description was wrong. We are correcting the wording.

One scope point you raised under "not checked"

You noted that _mcp.py still uses parse_arguments(). That is accurate: _prepare_content_for_mcp builds the outgoing MCP tool_use input from content.parse_arguments(), so it is not covered by the guard in this pull request. The issue's expectation is stated for the loop ("the loop should answer them the way it answers a missing required property"), and the reproduction in the issue exercises the in-process FunctionTool path, which is what this change fixes. Whether a malformed model-authored payload can reach that conversion in a given flow is a question we have not settled, and we would rather settle it than assume it cannot happen. We will check the reachability and either extend the guard there or scope this pull request to the loop and open a follow-up for the MCP conversion path.

Not checked on our side either: real provider payloads, the streaming path end to end, and pyright/mypy outside the changed file. We will keep the test evidence you produced as the reference for the next round.

We will come back with the follow-up commit for points 2, 3 and 4.

@Madin-H23
H23 (Madin-H23) deployed to github-app-auth October 22, 2026 05:29 — with GitHub Actions Active
…roval

A payload that is not arguments cannot become a working call by approving it,
so the model is now told what went wrong instead of a human being asked to
approve a call that can only fail. The guard is the one the invocation path
already uses.

The guard's docstring now records that it runs before the middleware pipeline,
so middleware does not see a malformed payload as a tool call.
@Madin-H23
H23 (Madin-H23) deployed to github-app-auth October 22, 2026 08:44 — with GitHub Actions Active
@Madin-H23
H23 (Madin-H23) deployed to github-app-auth October 22, 2026 08:45 — with GitHub Actions Active
@Madin-H23

Copy link
Copy Markdown
Author

Here is the follow-up for points 2, 3 and 4, and the answer to the reachability question you raised under "not checked". It is in a08f451.

2. The approval path — fixed

The check now also runs where the approval request is built, so a call whose arguments are not arguments is answered before a human is asked to approve it. It is the same guard the invocation path uses, and the call is answered the way a missing required property is answered, with the cause in the exception field.

The new test test_malformed_arguments_are_answered_before_the_call_is_sent_for_approval pins it: on the previous head the same call produced a function_approval_request — which is the behaviour you described — and it now produces a function_result carrying the argument-validation error, with no approval request surfaced and the tool never called.

3. Middleware ordering — recorded

The guard's docstring now states that it runs before the middleware pipeline, so a malformed payload is not seen by middleware as a tool call.

4. Description wording — corrected

The description now says that the cause is carried in the result's exception field and that the model-visible message is Error: Argument parsing failed. unless include_detailed_errors is on, which is the same convention the existing missing-required-property path uses. It also mentions the approval path now.

The MCP conversion path — the reachability question, answered

I traced it rather than assuming. _prepare_content_for_mcp builds the outgoing tool_use input from Content.parse_arguments(), and it is reached from _prepare_message_for_mcp, which the MCP sampling callback uses to turn a model response into MCP content. So in that flow a model-authored payload that is not arguments is still forwarded with the raw wrapper: the guard in this pull request does not cover that route, and I have not changed it.

This pull request is therefore scoped to the function-calling loop, which is what the issue asks for — "the loop should answer them the way it answers a missing required property" — and what its reproduction exercises. The sampling path needs a decision that does not exist yet: what an MCP client should receive for a call that cannot run. That is the same kind of maintainer-level call as the trade-off you raised, so I would rather ask than guess, and extending the guard there would change the MCP error surface of a route this change does not touch. I have recorded the reachability analysis here and will put that question to the maintainers alongside the one you raised.

Verification

  • the new test fails on the previous head and passes on this one;
  • the full packages/core/tests suite, run on this head and on the previous head, reports the same 128 failures in both runs — they are the environment-dependent ones, from optional dependencies missing in my environment — plus the two new cases failing only on the previous head. No test that passed before fails now;
  • ruff check and ruff format --check (0.17.0) are clean on both changed files;
  • pyright reports no errors on the changed file.

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.

One function-loop contract issue should be addressed before merge.


parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {})
try:
parsed_args = _parse_function_call_arguments(function_call_content, tool.name)

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.

Can we move this validation into the innermost handler, after the function middleware has run? Middleware should receive the raw parsed mapping and have a chance to repair the arguments before we return an error, consistent with the existing function-loop contract. If the arguments are still invalid after middleware, then return the argument-validation result immediately before tool execution.

Address review feedback: the function-loop contract lets middleware
receive the raw parsed mapping and repair it before any final
validation, so the not-a-JSON-object answer moves into the innermost
handler.

- A payload that is not a JSON object keeps reaching middleware as the
  raw parsed mapping; argument preparation is skipped for it, since the
  model would silently drop the wrapper as an extra field.
- The innermost handler answers a call whose arguments are still the
  wrapped payload, immediately before the tool would run; a repaired
  call executes like any other.
- A malformed call is no longer answered before approval: middleware
  may still repair it once the call is approved.
@Madin-H23
H23 (Madin-H23) deployed to github-app-auth October 23, 2026 04:53 — with GitHub Actions Active
@Madin-H23

Copy link
Copy Markdown
Author

Moved, in 130f0f9 - the validation now lives in the innermost handler and the middleware pipeline sees the payload first.

  • A payload that is not a JSON object still reaches middleware as the raw parsed mapping (parse_arguments()'s {"raw": ...} wrapper). One detail needed care on the way: preparation has to be skipped for such a payload, because tool._prepare_arguments() validates through the input model, which silently drops the wrapper as an extra field - middleware would have received {} and never see what the model actually sent.
  • The innermost handler answers a call whose arguments are still the wrapped payload, immediately before the tool would run, with the same argument-validation result as before. If middleware repaired the arguments, the call executes like any other.
  • The pre-approval answer is gone: a malformed call is now sent for approval like every other call, since middleware may still repair it afterwards.

Tests: the pre-approval assertion is replaced with one asserting the approval request surfaces; a middleware-repair case (raw wrapper -> repaired arguments -> tool runs) and an unrepaired case (middleware passes it through -> answered before execution) cover the new contract. test_tools, test_function_invocation_logic and test_middleware are green locally apart from two aiohttp tests that fail on this machine with the same failure on the previous head, so they are environmental.

This branch was successfully deployed

1 active deployment
github-app-auth — 130f0f98 Deployed Oct 23, 2026 by Madin-H23 via add_label #25996
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.

Python: [Bug]: Function call arguments that are not a JSON object reach the tool as {"raw": ...} instead of failing validation

4 participants