From cc1116ee4858abb9502c289d67b28d027701ef06 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 12 May 2026 12:10:31 +0530 Subject: [PATCH] fix(fast_depends): keep positional args out of **kwargs when passed by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a function had positional-or-keyword parameters alongside a **kwargs and all arguments were supplied as keyword arguments (e.g. an LLM tool call that delivers every argument by name), the positional parameter names were not pulled out of the kwargs dict before the rest was assigned to var_keyword_arg. The whole input — including arg1, arg2 — was then placed inside the kwargs field, and pydantic validation reported the positional fields as missing: Field required [type=missing, input_value={'kwargs': {'arg1': ...}}] Pop the positional_arg names from kwargs first whenever var_keyword_arg is set, mirroring the existing pop loop for keyword_args. The subsequent positional-from-*args loop is unaffected: it only runs when args is supplied, which is mutually exclusive with passing the same name as a keyword. Add a regression test in both the sync and async test_cast suites. Fixes #1790 --- autogen/fast_depends/core/model.py | 9 +++++++++ test/fast_depends/async/test_cast.py | 15 +++++++++++++++ test/fast_depends/sync/test_cast.py | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/autogen/fast_depends/core/model.py b/autogen/fast_depends/core/model.py index fdc0a41cc256..7bdedc4a84c4 100644 --- a/autogen/fast_depends/core/model.py +++ b/autogen/fast_depends/core/model.py @@ -219,6 +219,15 @@ def _solve( if (v := kwargs.pop(arg, Parameter.empty)) is not Parameter.empty: kw[arg] = v + # Positional parameters can also be supplied by name (e.g. when an LLM + # tool call delivers all arguments as keyword args). Pull them out + # before assigning the remainder to ``var_keyword_arg`` so they don't + # get swallowed into ``**kwargs``. + if self.var_keyword_arg is not None: + for arg in self.positional_args: + if (v := kwargs.pop(arg, Parameter.empty)) is not Parameter.empty: + kw[arg] = v + if self.var_keyword_arg is not None: kw[self.var_keyword_arg] = kwargs else: diff --git a/test/fast_depends/async/test_cast.py b/test/fast_depends/async/test_cast.py index 1b0cf6ccaf45..3b4c89aa9ccb 100644 --- a/test/fast_depends/async/test_cast.py +++ b/test/fast_depends/async/test_cast.py @@ -157,6 +157,21 @@ async def simple_func( assert await simple_func(1.0, 2.0, 3, b=3.0, key=1.0) == (1, (2.0, 3.0), 3, {"key": 1}) +@pytest.mark.anyio +async def test_positional_args_passed_by_name_with_var_keyword(): + """Regression for ag2 #1790 — async path.""" + + @inject + async def simple_func( + arg1: str, + arg2: str, + **kwargs: dict[str, str], + ): + return arg1, arg2, kwargs + + assert await simple_func(arg1="x", arg2="y", extra="z") == ("x", "y", {"extra": "z"}) + + @pytest.mark.anyio async def test_args_kwargs_2(): @inject diff --git a/test/fast_depends/sync/test_cast.py b/test/fast_depends/sync/test_cast.py index 55f3f0cc1949..153844aa3013 100644 --- a/test/fast_depends/sync/test_cast.py +++ b/test/fast_depends/sync/test_cast.py @@ -157,6 +157,25 @@ def simple_func( assert simple_func(1.0, 2.0, 3, b=3.0, key=1.0) == (1, (2.0, 3.0), 3, {"key": 1}) +def test_positional_args_passed_by_name_with_var_keyword(): + """Regression for ag2 #1790. + + When a function has positional-or-keyword params and a ``**kwargs``, all + arguments may arrive as keyword args (e.g. from an LLM tool call). The + positional names must not be swept into ``**kwargs``. + """ + + @inject + def simple_func( + arg1: str, + arg2: str, + **kwargs: dict[str, str], + ): + return arg1, arg2, kwargs + + assert simple_func(arg1="x", arg2="y", extra="z") == ("x", "y", {"extra": "z"}) + + def test_args_kwargs_2(): @inject def simple_func(