This is an automated email from the ASF dual-hosted git repository. kaxil pushed a commit to branch durable-tool-approval in repository https://gitbox.apache.org/repos/asf/airflow.git
commit f5d4cd4fbd1c0b9ffd361c61c2bb72db6e74d203 Author: Kaxil Naik <[email protected]> AuthorDate: Wed Nov 11 07:30:09 2026 +0000 Rename the durable callable toolset id helper and tidy step naming Rename callable_toolset_id to get_callable_toolset_id. The step namer now rewrites only the call_tool name: the validate_args name it also rewrote is never journaled, since argument validation runs live. Say in the tests why they fail if pydantic-ai stops reading the private FunctionToolset._id that durable runs write on a copy, and elide the memory address in the sample error in the durable execution docs. --- providers/common/ai/docs/durable_execution.rst | 2 +- .../src/airflow/providers/common/ai/durable/capability.py | 13 +++++++------ .../ai/tests/unit/common/ai/durable/test_capability.py | 13 +++++++++---- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/providers/common/ai/docs/durable_execution.rst b/providers/common/ai/docs/durable_execution.rst index 1e485079fa2..01e6dc07408 100644 --- a/providers/common/ai/docs/durable_execution.rst +++ b/providers/common/ai/docs/durable_execution.rst @@ -107,7 +107,7 @@ So does a callable that has no ``__qualname__`` to name it by, such as a .. code-block:: text - ValueError: durable=True names the steps of a toolset built per run after the function that builds it, but functools.partial(<function carrier_tools at 0xffff89c904a0>, carrier='dhl') has no __qualname__. Pass it as DynamicToolset(function, id=...). + ValueError: durable=True names the steps of a toolset built per run after the function that builds it, but functools.partial(<function carrier_tools at 0x...>, carrier='dhl') has no __qualname__. Pass it as DynamicToolset(function, id=...). Two different toolsets with the same id are refused, since their calls would be filed under one name, and so are two functions with the same qualified name, such as two diff --git a/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py b/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py index f3adf01aa08..0433ef47f11 100644 --- a/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py +++ b/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py @@ -105,7 +105,7 @@ else: _DAG_MODULE_PREFIX = re.compile(r"^unusual_prefix_[0-9a-f]{40}_") -def callable_toolset_id(function: object) -> str | None: +def get_callable_toolset_id(function: object) -> str | None: """ Return the id of a toolset that ``function`` builds per run: its module and qualified name. @@ -157,12 +157,12 @@ def give_durable_ids( it has an id. An id-less ``FunctionToolset`` gets one numbered in the order the toolsets are given, which its step names leave out (a function tool's step is named after the tool); a function that builds a toolset per run, or an id-less ``DynamicToolset``, gets - :func:`callable_toolset_id`. A leaf that already has an id, and an ``MCPToolset``, are + :func:`get_callable_toolset_id`. A leaf that already has an id, and an ``MCPToolset``, are left as they are. Leaves are copied, not changed, and ``given`` maps each leaf to its copy so a toolset listed twice stays one toolset to pydantic-ai. """ if not isinstance(toolset, AbstractToolset): - return DynamicToolset(toolset, id=callable_toolset_id(toolset)) + return DynamicToolset(toolset, id=get_callable_toolset_id(toolset)) if not any(_lacks_id(leaf) for leaf in iter_toolsets(toolset)): # Rebuilding wrappers via visit_and_replace breaks wrapper subclasses with their own __init__. return toolset @@ -176,7 +176,7 @@ def give_durable_ids( copied = DynamicToolset( leaf.toolset_func, per_run_step=leaf.per_run_step, - id=callable_toolset_id(leaf.toolset_func), + id=get_callable_toolset_id(leaf.toolset_func), ) else: numbered = sum(isinstance(other, FunctionToolset) for _, other in given.values()) @@ -508,13 +508,14 @@ class _AirflowOperationNamer(JournalOperationNamer): A function tool's step name ends in the tool's name, so the id Airflow numbered the toolset by adds nothing to it and would only make the name harder to read in the task log. An id the Dag author chose stays in the name, so steps recorded under it before - this naming existed replay as they did. + this naming existed replay as they did. Only a tool call is journaled under its name: + ``execute`` runs argument validation live. """ def operation_name(self, operation_id: DurableOperationId) -> str: name = super().operation_name(operation_id) if ( - isinstance(operation_id, ToolsetCallToolId | ToolsetValidateToolArgumentsId) + isinstance(operation_id, ToolsetCallToolId) and operation_id.toolset_kind == "function" and operation_id.toolset_id.startswith(_ASSIGNED_ID_PREFIX) ): diff --git a/providers/common/ai/tests/unit/common/ai/durable/test_capability.py b/providers/common/ai/tests/unit/common/ai/durable/test_capability.py index e951c59bf43..482ca00604b 100644 --- a/providers/common/ai/tests/unit/common/ai/durable/test_capability.py +++ b/providers/common/ai/tests/unit/common/ai/durable/test_capability.py @@ -55,7 +55,7 @@ from airflow.providers.common.ai.durable.base import RUNS_KEY from airflow.providers.common.ai.durable.capability import ( GivenIds, _JournalCodec, - callable_toolset_id, + get_callable_toolset_id, give_durable_ids, ) from airflow.providers.common.ai.durable.journal import DurableJournal, Unrecordable, journal_scope @@ -1205,6 +1205,11 @@ class TestToolsetIds: assert self.tool_step_names(memory_storage) == ["analyst__function_toolset__db.call_tool:query"] def test_gives_ids_to_copies_and_one_copy_to_a_toolset_listed_twice(self): + """ + ``give_durable_ids`` writes ``FunctionToolset``'s private ``_id``, which only its constructor sets. + + This test and the replay tests above fail if pydantic-ai stops reading the id from there. + """ shared = FunctionToolset() given: GivenIds = {} @@ -1241,14 +1246,14 @@ class TestToolsetIds: assert (given.id, given.per_run_step) == (f"{__name__}._build_tools", False) -class TestCallableToolsetId: +class TestGetCallableToolsetId: def test_leaves_out_the_path_hash_airflow_puts_in_a_dag_files_module(self): def build_tools(ctx: RunContext[None]) -> FunctionToolset[None]: return FunctionToolset() build_tools.__module__ = f"unusual_prefix_{'0123456789abcdef' * 2}01234567_orders_dag" - assert callable_toolset_id(build_tools) == f"orders_dag.{build_tools.__qualname__}" + assert get_callable_toolset_id(build_tools) == f"orders_dag.{build_tools.__qualname__}" @pytest.mark.parametrize( "function", @@ -1258,7 +1263,7 @@ class TestCallableToolsetId: ], ) def test_none_for_a_callable_without_a_qualified_name(self, function): - assert callable_toolset_id(function) is None + assert get_callable_toolset_id(function) is None class Answer(BaseModel):
