This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new c245439c5aa Add an example of a decision model approving an agent's
tool calls (#74697)
c245439c5aa is described below
commit c245439c5aa978870372863fd27823265918bfab
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Oct 14 09:41:24 2026 +0100
Add an example of a decision model approving an agent's tool calls (#74697)
A HandleDeferredToolCalls handler asks a decision model whether each call
that needs approval may run. A confident allow runs it, a confident deny
refuses it, and anything else, including a judge that cannot be reached,
goes to AgentOperator's existing per-tool approval pause.
---
providers/common/ai/docs/decision_models.rst | 6 +
providers/common/ai/docs/tool_approval.rst | 70 ++++++
.../ai/example_dags/example_agent_tool_judge.py | 188 +++++++++++++++++
.../common/ai/operators/test_agent_tool_judge.py | 234 +++++++++++++++++++++
4 files changed, 498 insertions(+)
diff --git a/providers/common/ai/docs/decision_models.rst
b/providers/common/ai/docs/decision_models.rst
index 3f32da16aa2..337b306d1a6 100644
--- a/providers/common/ai/docs/decision_models.rst
+++ b/providers/common/ai/docs/decision_models.rst
@@ -194,6 +194,12 @@ Where it fits in this provider
taking arguments raises ``ToolCallProposed`` after the request, which
is a
``ModelAPIError`` rather than a refusal, so ``FallbackModel`` hands
those requests to
a text model behind it and only they cost a full call.
+ * - Approving an agent's tool calls
+ - Yes
+ - Whether a proposed call may run without a person is a three-way pick:
allow,
+ deny, or ask. A ``HandleDeferredToolCalls`` handler asks the decision
model for
+ each call that needs approval and sends an unsure answer to the task's
existing
+ approval pause; see :ref:`howto/tool_approval_judge`.
Reading the confidence
----------------------
diff --git a/providers/common/ai/docs/tool_approval.rst
b/providers/common/ai/docs/tool_approval.rst
index 744e9f36b1d..9baa0662483 100644
--- a/providers/common/ai/docs/tool_approval.rst
+++ b/providers/common/ai/docs/tool_approval.rst
@@ -130,6 +130,76 @@ The arguments shown to the reviewer pass through Airflow's
secrets masker first,
so a value under a key such as ``api_key`` or ``password``, or a value already
registered as a secret, is shown masked.
+.. _howto/tool_approval_judge:
+
+Let a decision model approve the routine calls
+----------------------------------------------
+
+Most refunds are routine, and a person who approves every one of them stops
reading
+them. A :doc:`decision model <decision_models>` can answer "may this call run
without
+a person?" for each one, and pydantic-ai's ``HandleDeferredToolCalls``
capability is
+the place to ask: its handler sees every call waiting for approval, with its
+validated arguments, before the task pauses. It approves the calls the model
is sure
+about, denies the ones the model is sure should not run, and leaves the rest.
The
+task pauses on whatever it leaves, exactly as above.
+
+A limit you can write down, such as "refunds over $50", needs no model: the
+``.approval_required()`` function above can check the arguments itself. The
model
+earns its call on the question a rule cannot answer, whether the call fits the
+request: the right order, an amount the customer's problem supports, an action
+somebody asked for.
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_agent_tool_judge.py
+ :language: python
+ :start-after: [START howto_agent_tool_judge]
+ :end-before: [END howto_agent_tool_judge]
+
+What happens to each call:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 30 35 35
+
+ * - The model answers
+ - Confidence at or above ``MIN_CONFIDENCE``
+ - Below it, or none reported
+ * - ``allow``
+ - The call runs.
+ - A person decides.
+ * - ``deny``
+ - The call does not run; the agent is told, as after a Reject.
+ - A person decides.
+ * - ``review``
+ - A person decides.
+ - A person decides.
+ * - The model call raises
+ - A person decides.
+ - A person decides.
+
+Once a person approves, the call runs without the model being asked again.
Once a
+call to a tool has been refused, by a person or by the model, later calls to
that
+tool in the same run go to a person: the model does not see why the earlier
call was
+refused, so it does not get to overrule that.
+
+**Keep hard limits in the tool.** A decision model reads a tool argument as
data, not
+as an attempt to manipulate it, so an argument written to argue for its own
approval
+can move the answer (see :doc:`decision_models`). ``within_order_total`` is the
+tool's ``args_validator``: it runs in the worker after the arguments validate
and
+before the call is marked for approval, so a refund above the order total goes
back
+to the agent without the model or a person being asked about it.
+
+**Set the bar from your own calls.** Run the handler over calls your agent
actually
+makes, and raise ``MIN_CONFIDENCE`` until the calls it lets through are ones
you
+would have approved. A model that hesitates sends more calls to a person, which
+costs time; one that is trusted too far costs whatever the tool does.
+
+The Required Actions page shows the call, not the model's answer; the handler
logs
+the answer and its confidence to the task log, where the reviewer can look. A
call the
+handler leaves for a person follows every rule on this page, including how
often one
+task instance can ask in a Dag run. Where tool approval is not available, such
a call
+fails the task. An exception the handler does not catch, such as a missing
+``decision_default`` connection, fails the task as well.
+
Requirements and limits
-----------------------
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_tool_judge.py
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_tool_judge.py
new file mode 100644
index 00000000000..bcaa10226ef
--- /dev/null
+++
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_tool_judge.py
@@ -0,0 +1,188 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Example Dag: a decision model decides the agent's refunds, and a person
decides the ones it is unsure of.
+
+Prerequisites: Airflow 3.3 or later, ``pydantic-ai-slim`` 2.46.0 or later, a
+``pydanticai_default`` connection for the agent's text model, and a
``decision_default``
+connection whose model is a decision model, such as TypeSafe Jev:
``password=<API key>``,
+``extra='{"model": "typesafe:jev-1.13.0"}'``, and the provider's ``typesafe``
extra.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from datetime import timedelta
+from enum import Enum
+
+from pydantic_ai import (
+ DeferredToolRequests,
+ DeferredToolResults,
+ ModelRetry,
+ RunContext,
+ Tool,
+ ToolDenied,
+ UseEnumMemberDocstrings,
+)
+from pydantic_ai.capabilities import HandleDeferredToolCalls
+from pydantic_ai.messages import ToolReturnPart, UserPromptPart
+from pydantic_ai.toolsets.function import FunctionToolset
+
+from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIHook
+from airflow.providers.common.ai.operators.agent import AgentOperator
+from airflow.providers.common.ai.utils.decision import ModelConfidence
+from airflow.providers.common.compat.sdk import dag
+
+log = logging.getLogger(__name__)
+
+
+# [START howto_agent_tool_judge]
+ORDERS = {7: 42.00}
+
+
+def lookup_order(order_id: int) -> str:
+ """Look up an order."""
+ return f"order {order_id}: ${ORDERS[order_id]:.2f}, carrier marked it lost"
+
+
+def refund_order(order_id: int, amount: float, reason: str) -> str:
+ """Refund an order. Irreversible: the money leaves the account."""
+ return f"refunded ${amount:.2f} on order {order_id}"
+
+
+def within_order_total(ctx: RunContext, order_id: int, amount: float, reason:
str) -> None:
+ """A rule, not a judgment: a refund it refuses never reaches the model or
a person."""
+ if order_id not in ORDERS:
+ raise ModelRetry(f"There is no order {order_id}.")
+ if amount > ORDERS[order_id]:
+ raise ModelRetry(f"Order {order_id} cost ${ORDERS[order_id]:.2f};
refund at most that.")
+
+
+shop = FunctionToolset(
+ tools=[
+ lookup_order,
+ Tool(refund_order, requires_approval=True,
args_validator=within_order_total),
+ ]
+)
+TOOL_DESCRIPTIONS = {tool.__name__: tool.__doc__ for tool in (lookup_order,
refund_order)}
+
+
+class Verdict(UseEnumMemberDocstrings, str, Enum):
+ """Whether a proposed tool call may run without a person checking it
first."""
+
+ allow = "allow"
+ """The call does what the request asks, on the right record, at a size the
request supports."""
+
+ review = "review"
+ """The call may be right, but a person should check it: an unusual amount,
an irreversible step the request does not clearly ask for, or context that is
missing."""
+
+ deny = "deny"
+ """The call does not match the request: the wrong record, an amount far
beyond what was asked, or an action nobody asked for."""
+
+
+# Below this, the answer is not acted on and the call goes to a person.
+MIN_CONFIDENCE = 0.7
+
+
+def customer_request(ctx: RunContext) -> str:
+ """The request the agent is working on. A run resumed after a pause has no
prompt, only history."""
+ if isinstance(ctx.prompt, str):
+ return ctx.prompt
+ prompts = [
+ part.content
+ for message in ctx.messages
+ for part in message.parts
+ if isinstance(part, UserPromptPart) and isinstance(part.content, str)
+ ]
+ return prompts[-1] if prompts else ""
+
+
+def refused_earlier(ctx: RunContext, tool_name: str) -> bool:
+ """Whether a person or the model refused a call to this tool earlier in
the run."""
+ return any(
+ isinstance(part, ToolReturnPart) and part.tool_name == tool_name and
part.outcome == "denied"
+ for message in ctx.messages
+ for part in message.parts
+ )
+
+
+async def judge_tool_calls(ctx: RunContext, requests: DeferredToolRequests) ->
DeferredToolResults:
+ """Approve or deny the calls the decision model is sure about; leave the
rest for a person."""
+ judge = PydanticAIHook(llm_conn_id="decision_default").create_agent(
+ output_type=Verdict,
+ instructions=(
+ "A support agent proposed the tool call below while handling the
customer request. "
+ "Decide whether it may run without a person checking it first."
+ ),
+ )
+ results = DeferredToolResults()
+ for call in requests.approvals:
+ if refused_earlier(ctx, call.tool_name):
+ # The model does not see why the earlier call was refused, so it
must not overrule that.
+ log.info(
+ "%s (%s) was refused earlier in this run; asking a person",
call.tool_name, call.tool_call_id
+ )
+ continue
+ question = (
+ f"Customer request:\n{customer_request(ctx)}\n\n"
+ f"Proposed tool call: {call.tool_name}\n"
+ f"What the tool does: {TOOL_DESCRIPTIONS.get(call.tool_name,
'unknown')}\n"
+ f"Arguments: {json.dumps(call.args_as_dict())}"
+ )
+ try:
+ answer = await judge.run(question)
+ except Exception:
+ # An unreachable judge is not a verdict: leave the call for a
person.
+ log.warning("Judge failed on %s; asking a person", call.tool_name,
exc_info=True)
+ continue
+ confidence =
ModelConfidence.from_result(answer).confidence.get("response")
+ acted_on = confidence is not None and confidence >= MIN_CONFIDENCE
+ # No arguments here: they can carry secrets, which the review page
masks and a log line would not.
+ log.info(
+ "Judge on %s (%s): %s at confidence %s, bar %s; %s",
+ call.tool_name,
+ call.tool_call_id,
+ answer.output.value,
+ confidence,
+ MIN_CONFIDENCE,
+ "acting on it" if acted_on and answer.output is not Verdict.review
else "asking a person",
+ )
+ if not acted_on:
+ continue
+ if answer.output is Verdict.allow:
+ results.approvals[call.tool_call_id] = True
+ elif answer.output is Verdict.deny:
+ results.approvals[call.tool_call_id] = ToolDenied("An automated
check refused this call.")
+ return results
+
+
+@dag(tags=["example"])
+def example_agent_tool_judge():
+ AgentOperator(
+ task_id="handle_ticket",
+ llm_conn_id="pydanticai_default",
+ prompt="The customer says order 7 never arrived. Resolve it.",
+ toolsets=[shop],
+ capabilities=[HandleDeferredToolCalls(handler=judge_tool_calls)],
+ tool_approval_timeout=timedelta(hours=4),
+ )
+
+
+# [END howto_agent_tool_judge]
+
+example_agent_tool_judge()
diff --git
a/providers/common/ai/tests/unit/common/ai/operators/test_agent_tool_judge.py
b/providers/common/ai/tests/unit/common/ai/operators/test_agent_tool_judge.py
new file mode 100644
index 00000000000..3f9596a3a0d
--- /dev/null
+++
b/providers/common/ai/tests/unit/common/ai/operators/test_agent_tool_judge.py
@@ -0,0 +1,234 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""``example_agent_tool_judge``: a decision model judging the calls that need
approval in AgentOperator."""
+
+from __future__ import annotations
+
+import functools
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+# The example's Verdict uses UseEnumMemberDocstrings, new in pydantic-ai-slim
2.46.0.
+pytest.importorskip("pydantic_ai", minversion="2.46.0")
+
+from pydantic_ai import Agent
+from pydantic_ai.messages import (
+ ModelResponse,
+ RetryPromptPart,
+ TextPart,
+ ToolCallPart,
+ ToolReturnPart,
+ UserPromptPart,
+)
+from pydantic_ai.models.function import AgentInfo, FunctionModel
+
+from airflow.providers.common.ai.example_dags import example_agent_tool_judge
as example
+from airflow.providers.common.ai.operators.agent import AgentOperator
+
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
+from unit.common.ai.operators.test_agent_tool_approval import (
+ APPROVE,
+ UPSERT,
+ _context,
+ _FakeTaskStateStore,
+ _returns,
+)
+
+pytestmark = pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="Per-tool
approval needs Airflow 3.3+.")
+
+if AIRFLOW_V_3_3_PLUS:
+ from airflow.sdk.exceptions import TaskAwaitingInput
+
+JUDGE_HOOK =
"airflow.providers.common.ai.example_dags.example_agent_tool_judge.PydanticAIHook"
+REQUEST = "The customer says order 7 never arrived. Resolve it."
+
+
+def _refunds_of(*amounts: float):
+ """An agent that proposes one refund of order 7 per step, in ``amounts``
order, then reports the results."""
+
+ def model(messages, info: AgentInfo) -> ModelResponse:
+ answered = [p for m in messages for p in m.parts if isinstance(p,
(ToolReturnPart, RetryPromptPart))]
+ if len(answered) < len(amounts):
+ step = len(answered)
+ args = {"order_id": 7, "amount": amounts[step], "reason": "lost
parcel"}
+ return ModelResponse(parts=[ToolCallPart("refund_order", args,
tool_call_id=f"c-{step}")])
+ return ModelResponse(parts=[TextPart(" | ".join(_returns(messages)))])
+
+ return model
+
+
+def _judge(answers: list[tuple[str, float | None]], questions: list[str]):
+ """A decision model giving ``answers`` in order, as (verdict, confidence),
reported the way Jev does."""
+ pending = list(answers)
+
+ def model(messages, info: AgentInfo) -> ModelResponse:
+ questions.extend(str(p.content) for m in messages for p in m.parts if
isinstance(p, UserPromptPart))
+ verdict, confidence = pending.pop(0)
+ return ModelResponse(
+ parts=[ToolCallPart(info.output_tools[0].name, {"response":
verdict})],
+ provider_details=None if confidence is None else {"confidence":
{"response": confidence}},
+ )
+
+ return model
+
+
+def _unreachable(messages, info: AgentInfo) -> ModelResponse:
+ raise ConnectionError("decision model unreachable")
+
+
+def _operator(agent_model) -> AgentOperator:
+ """The example Dag's task, with the agent's text model replaced."""
+ op = example.example_agent_tool_judge().get_task("handle_ticket")
+ assert isinstance(op, AgentOperator)
+ hook = MagicMock(spec=["create_agent"])
+ hook.create_agent.side_effect = lambda **kw:
Agent(FunctionModel(agent_model), **kw)
+ op.llm_hook = hook
+ return op
+
+
[email protected]
+def judge():
+ """Stand in for the ``decision_default`` connection; set ``judge.model``
to a FunctionModel callable."""
+ with patch(JUDGE_HOOK, autospec=True) as hook_cls:
+ state = MagicMock(spec=["model"])
+ hook_cls.return_value.create_agent.side_effect = lambda **kw:
Agent(FunctionModel(state.model), **kw)
+ yield state
+
+
[email protected]
+def refunds():
+ """The arguments of every refund whose body ran."""
+ calls: list[dict[str, Any]] = []
+ schema = example.shop.tools["refund_order"].function_schema
+
+ @functools.wraps(example.refund_order)
+ def recording_refund(**kwargs):
+ calls.append(kwargs)
+ return example.refund_order(**kwargs)
+
+ with patch.object(schema, "function", recording_refund):
+ yield calls
+
+
+def _execute(op: AgentOperator, store: _FakeTaskStateStore | None = None):
+ with patch(UPSERT, autospec=True) as upsert:
+ output = op.execute(_context(store or _FakeTaskStateStore()))
+ upsert.assert_not_called()
+ return output
+
+
+def _pause(op: AgentOperator, store: _FakeTaskStateStore) -> TaskAwaitingInput:
+ with patch(UPSERT, autospec=True) as upsert,
pytest.raises(TaskAwaitingInput) as paused:
+ op.execute(_context(store))
+ assert "**refund_order**" in upsert.call_args.kwargs["body"]
+ return paused.value
+
+
+def test_a_confident_allow_runs_the_refund_without_asking_a_person(judge,
refunds):
+ questions: list[str] = []
+ judge.model = _judge([("allow", 0.95)], questions)
+
+ output = _execute(_operator(_refunds_of(42.0)))
+
+ assert refunds == [{"order_id": 7, "amount": 42.0, "reason": "lost
parcel"}]
+ assert output == "refunded $42.00 on order 7"
+ assert REQUEST in questions[0]
+ assert '"amount": 42.0' in questions[0]
+ assert "Irreversible" in questions[0]
+
+
+def test_a_confident_deny_never_runs_the_refund(judge, refunds):
+ judge.model = _judge([("deny", 0.95)], [])
+
+ output = _execute(_operator(_refunds_of(42.0)))
+
+ assert refunds == []
+ assert output == "An automated check refused this call."
+
+
[email protected](
+ "answer",
+ [
+ pytest.param(("allow", 0.5), id="unsure-allow"),
+ pytest.param(("deny", 0.5), id="unsure-deny"),
+ pytest.param(("review", 0.99), id="review"),
+ pytest.param(("allow", None), id="no-confidence-reported"),
+ pytest.param(None, id="judge-unreachable"),
+ ],
+)
+def test_an_unsure_missing_or_failed_answer_asks_a_person(judge, refunds,
answer):
+ judge.model = _unreachable if answer is None else _judge([answer], [])
+
+ paused = _pause(_operator(_refunds_of(42.0)), _FakeTaskStateStore())
+
+ assert refunds == []
+ assert paused.kwargs["tool_call_ids"] == ["c-0"]
+
+
+def test_the_judge_is_not_asked_again_about_a_call_a_person_approved(judge,
refunds):
+ questions: list[str] = []
+ judge.model = _judge([("review", 0.99)], questions)
+ store = _FakeTaskStateStore()
+ paused = _pause(_operator(_refunds_of(42.0)), store)
+
+ output = _operator(_refunds_of(42.0)).resume_after_tool_approval(
+ _context(store), **paused.kwargs, event=APPROVE
+ )
+
+ assert len(questions) == 1
+ assert refunds == [{"order_id": 7, "amount": 42.0, "reason": "lost
parcel"}]
+ assert output == "refunded $42.00 on order 7"
+
+
+def test_a_resumed_run_still_shows_the_judge_the_customer_request(judge,
refunds):
+ questions: list[str] = []
+ judge.model = _judge([("review", 0.99), ("allow", 0.95)], questions)
+ store = _FakeTaskStateStore()
+ paused = _pause(_operator(_refunds_of(42.0, 5.0)), store)
+
+ _operator(_refunds_of(42.0, 5.0)).resume_after_tool_approval(
+ _context(store), **paused.kwargs, event=APPROVE
+ )
+
+ assert [r["amount"] for r in refunds] == [42.0, 5.0]
+ assert REQUEST in questions[1]
+ assert '"amount": 5.0' in questions[1]
+
+
+def
test_a_refund_above_the_order_total_reaches_neither_the_judge_nor_a_person(judge,
refunds):
+ questions: list[str] = []
+ judge.model = _judge([("allow", 0.95)], questions)
+
+ output = _execute(_operator(_refunds_of(4200.0, 42.0)))
+
+ assert refunds == [{"order_id": 7, "amount": 42.0, "reason": "lost
parcel"}]
+ assert len(questions) == 1
+ assert '"amount": 42.0' in questions[0]
+ assert output == "refunded $42.00 on order 7"
+
+
+def test_a_tool_refused_earlier_in_the_run_goes_to_a_person(judge, refunds):
+ questions: list[str] = []
+ judge.model = _judge([("deny", 0.95), ("allow", 0.99)], questions)
+
+ paused = _pause(_operator(_refunds_of(42.0, 30.0)), _FakeTaskStateStore())
+
+ assert refunds == []
+ assert len(questions) == 1
+ assert paused.kwargs["tool_call_ids"] == ["c-1"]