kaxil commented on code in PR #76752:
URL: https://github.com/apache/airflow/pull/76752#discussion_r4532064752


##########
providers/common/ai/src/airflow/providers/common/ai/tools/__init__.py:
##########
@@ -151,35 +156,50 @@ def airflow_tools(self) -> Sequence[AirflowTool]:
         ...
 
 
-def collect_tools(sources: Iterable[ToolProvider | AirflowTool]) -> 
list[AirflowTool]:
+ToolSource: TypeAlias = (
+    ToolProvider | AirflowTool | FilteredToolset[Any] | PrefixedToolset[Any] | 
RenamedToolset[Any]
+)
+"""What a framework adapter accepts: a tool, or a toolset that 
``.filtered()``, ``.prefixed()`` and ``.renamed()`` may wrap."""
+
+
+def collect_tools(sources: Iterable[ToolSource]) -> list[AirflowTool]:
     """
     Flatten toolsets and individual tools, as a framework adapter receives 
them, into tools.
 
+    A toolset wrapped with ``.filtered()``, ``.prefixed()`` or ``.renamed()`` 
contributes the
+    tools the wrappers offer, under the names they give them. The 
``.filtered()`` function runs
+    once, here, with an inert run context, so a filter that reads per-run 
state does not
+    re-evaluate it before each model turn.
+
+    :raises TypeError: when a source is another wrapper, such as 
``.approval_required()``, or
+        has no ``airflow_tools()`` method.
     :raises MCPToolsetNotSupportedError: when a source is an ``MCPToolset``. 
Connect the
         framework's own MCP client to the server instead.
     :raises ValueError: when two tools share a name. Frameworks route a call 
by name, so one
         of them would silently never run; give the toolsets different 
``tool_prefix`` values,
-        where they take one.
+        where they take one, or wrap them with ``.prefixed()``. Also when a 
``.renamed()`` map
+        names a tool the toolset does not have.

Review Comment:
   Reworded to the real trigger: an offered name that a `.renamed()` map sends 
to a name the toolset doesn't have, such as a map written backwards. I left the 
mistyped-key case silent on purpose, since that's what pydantic-ai's 
`RenamedToolset` does inside `AgentOperator` too, so both paths behave the 
same. Happy to add a check if you'd rather we're stricter than pydantic-ai here?



##########
providers/common/ai/src/airflow/providers/common/ai/tools/_from_toolset.py:
##########
@@ -88,12 +93,102 @@ def airflow_tools_from_toolset(toolset: 
AbstractToolset[Any], *, deps: Any = Non
     :param toolset: The pydantic-ai toolset to expose.
     :param deps: Exposed to the toolset as ``ctx.deps``.
     """
-    ctx: RunContext[Any] = RunContext(deps=deps, model=TestModel(), 
usage=RunUsage(), max_retries=1)
+    ctx = _inert_context(deps)
     toolset_tools = run_coroutine_sync(toolset.get_tools(ctx))
     in_order = _InOrder()
     return [_as_airflow_tool(toolset, name, tool, ctx, in_order) for name, 
tool in toolset_tools.items()]
 
 
+def _inert_context(deps: Any = None) -> RunContext[Any]:
+    return RunContext(deps=deps, model=TestModel(), usage=RunUsage(), 
max_retries=1)
+
+
+# The wrappers that only choose which tools the model is offered, and under 
what name.
+_NAMING_WRAPPERS = (FilteredToolset, PrefixedToolset, RenamedToolset)
+
+
+def _is_naming_wrapper(
+    toolset: object,
+) -> TypeGuard[FilteredToolset[Any] | PrefixedToolset[Any] | 
RenamedToolset[Any]]:
+    # Exact types: the tools reached around a wrapper skip its call_tool, 
which a subclass may override.
+    return type(toolset) in _NAMING_WRAPPERS
+
+
+def airflow_tools_of(source: object) -> list[AirflowTool]:
+    """
+    Return the tools of a toolset a framework adapter was given.
+
+    A source with an ``airflow_tools()`` method is asked for them. A toolset 
wrapped with
+    ``.filtered()``, ``.prefixed()`` or ``.renamed()``, nested in any order, 
gives the innermost
+    toolset's own ``airflow_tools()``, so they behave as they do without the 
wrappers. The
+    wrappers' own ``get_tools`` decides which of them the model is offered and 
under what name,
+    given the same inert ``RunContext`` :func:`airflow_tools_from_toolset` 
passes. A ``.filtered()``
+    function therefore runs once, when the adapter is built, rather than 
before every model turn
+    as in a pydantic-ai run.
+
+    :raises TypeError: when ``source`` is any other wrapper, such as 
``.approval_required()``,
+        or there is no ``airflow_tools()`` method under its wrappers.
+    :raises ValueError: when a ``.renamed()`` map names a tool the toolset 
does not have.

Review Comment:
   Done, both docstrings now say the same thing.



##########
providers/common/ai/tests/unit/common/ai/tools/test_tools.py:
##########
@@ -153,6 +165,183 @@ def airflow_tools(self) -> list[AirflowTool]:
         with pytest.raises(NotImplementedError, match="^not yet$"):
             _collect_tools([_Unfinished()], mcp_hint="Connect Acme's McpClient 
instead.")
 
+    def test_a_mock_toolset_is_asked_for_its_tools(self):

Review Comment:
   Fair. Dropped the mock test. Kept the own-tools one as a guard, and its 
docstring now says it passes before this change too.



##########
providers/common/ai/tests/unit/common/ai/tools/test_tools.py:
##########
@@ -153,6 +165,183 @@ def airflow_tools(self) -> list[AirflowTool]:
         with pytest.raises(NotImplementedError, match="^not yet$"):
             _collect_tools([_Unfinished()], mcp_hint="Connect Acme's McpClient 
instead.")
 
+    def test_a_mock_toolset_is_asked_for_its_tools(self):
+        """The mock has ``airflow_tools`` only through ``__getattr__``, which 
a Protocol check misses on Python 3.12+."""
+        source = MagicMock(spec=["airflow_tools"])
+        source.airflow_tools.return_value = [_named("a")]
+
+        assert [tool.name for tool in collect_tools([source])] == ["a"]
+
+
+def _sql_toolset(name: str = "Ada") -> SQLToolset:
+    ts = SQLToolset("pg_default")
+    ts._hook = _make_mock_db_hook(records=[(1, name)], 
last_description=[("id",), ("name",)])
+    return ts
+
+
+def _names(tools: list[AirflowTool]) -> list[str]:
+    return [tool.name for tool in tools]
+
+
+def _not_query(ctx: RunContext[Any], tool: ToolDefinition) -> bool:
+    return tool.name != "query"
+
+
+async def _not_query_async(ctx: RunContext[Any], tool: ToolDefinition) -> bool:
+    return tool.name != "query"
+
+
+class TestCollectToolsThroughWrappers:
+    @pytest.mark.parametrize("keep", [_not_query, _not_query_async], 
ids=["sync", "async"])
+    def test_a_filter_drops_the_tools_it_rejects(self, keep):
+        tools = collect_tools([_sql_toolset().filtered(keep)])
+
+        assert _names(tools) == ["list_tables", "get_schema", "check_query"]
+
+    def test_the_filter_runs_once_with_each_tools_definition(self):
+        seen: list[tuple[RunContext[Any], ToolDefinition]] = []
+
+        def keep_all(ctx: RunContext[Any], tool: ToolDefinition) -> bool:
+            seen.append((ctx, tool))
+            return True
+
+        tools = collect_tools([_sql_toolset().filtered(keep_all)])
+        asyncio.run({tool.name: tool for tool in tools}["query"].call({"sql": 
"SELECT 1"}))
+
+        assert [tool.name for _, tool in seen] == ["list_tables", 
"get_schema", "query", "check_query"]
+        # The toolset's own definitions, not ones rebuilt from the AirflowTool.
+        assert all(tool.sequential for _, tool in seen)
+        assert all(ctx.deps is None for ctx, _ in seen)
+
+    @pytest.mark.parametrize(
+        ("wrap", "offered", "query_tool"),
+        [
+            pytest.param(
+                lambda ts: ts.prefixed("wh"),
+                ["wh_list_tables", "wh_get_schema", "wh_query", 
"wh_check_query"],
+                "wh_query",
+                id="prefixed",
+            ),
+            pytest.param(
+                lambda ts: ts.renamed({"run_sql": "query"}),
+                ["list_tables", "get_schema", "run_sql", "check_query"],
+                "run_sql",
+                id="renamed",
+            ),
+        ],
+    )
+    def test_a_renamed_tool_runs_the_inner_tool(self, wrap, offered, 
query_tool):
+        tools = {tool.name: tool for tool in 
collect_tools([wrap(_sql_toolset())])}
+
+        result = asyncio.run(tools[query_tool].call({"sql": "SELECT id, name 
FROM users"}))
+
+        assert list(tools) == offered
+        assert json.loads(result.content)["rows"] == [[1, "Ada"]]
+
+    def test_nested_wrappers_apply_from_the_inside_out(self):

Review Comment:
   Added: `.renamed()` inside `.prefixed()`, a tool whose name already carries 
the prefix, a filter that rejects every tool (plus an empty MCP server in the 
Claude Agent SDK adapter), and a rename that collides with an existing tool, 
which hits pydantic-ai's `UserError`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to