kaxil commented on code in PR #76882:
URL: https://github.com/apache/airflow/pull/76882#discussion_r4555909692
##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -237,11 +260,134 @@ def _answered_request(event: dict[str, Any]) -> int |
None:
return -1
-def _is_awaiting_input(error: BaseException) -> bool:
+def _is_awaiting_input(error: BaseException) -> TypeGuard[TaskAwaitingInput]:
"""Whether ``error`` is the pause for tool approval, which only Airflow
3.3+ raises."""
return AIRFLOW_V_3_3_PLUS and isinstance(error, TaskAwaitingInput)
+def _refuse_external_calls(requests: DeferredToolRequests) -> None:
+ """Fail on tool calls that hand their work to an external system, which
AgentOperator cannot resolve."""
+ if requests.calls:
+ raise UnsupportedToolDeferralError(
+ "The agent called tools that need external execution "
+ f"({', '.join(call.tool_name for call in requests.calls)});
AgentOperator only "
+ "supports tools that need approval."
+ )
+
+
+def _call_content(call: ToolCallPart) -> str | None:
Review Comment:
Done, it's `_fingerprint_call_content` now (8eae500733f).
##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -237,11 +260,134 @@ def _answered_request(event: dict[str, Any]) -> int |
None:
return -1
-def _is_awaiting_input(error: BaseException) -> bool:
+def _is_awaiting_input(error: BaseException) -> TypeGuard[TaskAwaitingInput]:
"""Whether ``error`` is the pause for tool approval, which only Airflow
3.3+ raises."""
return AIRFLOW_V_3_3_PLUS and isinstance(error, TaskAwaitingInput)
+def _refuse_external_calls(requests: DeferredToolRequests) -> None:
+ """Fail on tool calls that hand their work to an external system, which
AgentOperator cannot resolve."""
+ if requests.calls:
+ raise UnsupportedToolDeferralError(
+ "The agent called tools that need external execution "
+ f"({', '.join(call.tool_name for call in requests.calls)});
AgentOperator only "
+ "supports tools that need approval."
+ )
+
+
+def _call_content(call: ToolCallPart) -> str | None:
+ """Fingerprint what a reviewer sees of a call, its tool and arguments, but
not the id the model gave it."""
+ return fingerprint_tool_call(call.tool_name, call.args_as_dict(), None)
+
+
+@dataclass
+class _ToolApprovalAnswer:
+ """
+ A reviewer's answer, and what it was given for.
+
+ :param step: Which approval step of the run asked, counting replayed ones,
so the answer
+ never reaches another step, such as a later call with the same
arguments.
+ :param calls: The calls the reviewer saw, as ``(tool name, content
fingerprint)``.
+ :param toolset_ids: The agent's rendered toolset ids when the reviewer
answered.
+ """
+
+ step: int
+ calls: list[tuple[str, str | None]]
+ toolset_ids: list[str]
+ approval: bool | ToolDenied
+
+ def take(self, pending: Sequence[ToolCallPart]) -> dict[str, bool |
ToolDenied]:
+ """
+ Return the answer for each pending call it was given for, by the
call's current id.
+
+ A call matches on its tool and arguments, so a model request asked
again on resume, whose
+ calls get new ids, still gets the answer, and a different call with a
reused id does not.
+ Each answered call is used once: a step asked again can hold the same
call twice where
+ the reviewer saw it once.
+ """
+ settled: dict[str, bool | ToolDenied] = {}
+ for call in pending:
+ answered = (call.tool_name, _call_content(call))
+ if answered[1] is not None and answered in self.calls:
+ self.calls.remove(answered)
+ settled[call.tool_call_id] = self.approval
+ return settled
+
+ def dump(self) -> dict[str, Any]:
+ denied = self.approval.message if isinstance(self.approval,
ToolDenied) else None
+ return {
+ "step": self.step,
+ "calls": [list(call) for call in self.calls],
+ "toolset_ids": self.toolset_ids,
+ "denied": denied,
+ }
+
+ @classmethod
+ def load(cls, stored: dict[str, Any]) -> _ToolApprovalAnswer:
+ denied = stored["denied"]
+ return cls(
+ step=stored["step"],
+ calls=[(name, fingerprint) for name, fingerprint in
stored["calls"]],
+ toolset_ids=stored["toolset_ids"],
+ approval=True if denied is None else ToolDenied(denied),
+ )
+
+
+@dataclass
+class _DurableToolApproval(AbstractCapability[Any]):
+ """
+ Settles the tool calls a durable run is waiting to have approved, as a
step of its journal.
+
+ It runs first (outermost) and settles every call or parks the task, so the
whole decision is
+ one step, ``decide``: the reviewers' answers, then the other capabilities'
+ ``handle_deferred_tool_calls`` handlers, then a request for whatever is
left. A parked step
+ is not recorded, so the resumed run, which replays every step before it,
reaches it again
+ with the answer. Once recorded, a retry replays the decision instead of
asking anyone again,
+ and replays the approved calls instead of running them twice.
+
+ :param next_step: Number the approval step about to run for these calls,
replayed or not,
+ or return ``None`` when ``decide`` itself is asking the other handlers.
+ :param settle: The body of ``decide``.
+ """
+
+ next_step: Callable[[DeferredToolRequests], int | None] = field(repr=False)
+ settle: Callable[[RunContext[Any], DeferredToolRequests, int],
Awaitable[DeferredToolResults]] = field(
+ repr=False
+ )
+
+ @classmethod
+ def get_serialization_name(cls) -> str | None:
+ return None
+
+ def get_ordering(self) -> CapabilityOrdering:
+ # Ahead of any handler the Dag author ordered first, so none decides a
call before the reviewer.
+ return CapabilityOrdering(position="outermost")
+
+ async def handle_deferred_tool_calls(
+ self, ctx: RunContext[Any], *, requests: DeferredToolRequests
+ ) -> DeferredToolResults | None:
+ if (step := self.next_step(requests)) is None:
+ return None
+ return await self.decide(ctx, requests=requests, step=step)
+
+ @durable_operation(name="tool_approval")
+ async def decide(
+ self, ctx: RunContext[Any], *, requests: DeferredToolRequests, step:
int
+ ) -> DeferredToolResults | None:
+ return await self.settle(ctx, requests, step)
+
+
+def _only_for(requests: DeferredToolRequests, results: DeferredToolResults) ->
DeferredToolResults:
Review Comment:
Done, renamed to `_filter_results_to_requests` in 8eae500733f.
##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -1435,6 +1613,184 @@ def _resume_after_tool_approval(
context, result, attempt_usage=subtract_run_usage(run_usage,
self._run_usage_base)
)
+ def _check_review_answer(self, event: dict[str, Any], toolset_ids:
list[str]) -> None:
+ """Refuse to resume when the review ended without an answer, or the
toolsets changed while it waited."""
+ if "error" in event:
+ if event.get("error_type") == "timeout":
+ raise HITLTimeoutError(f"Tool approval timed out:
{event['error']}")
+ raise HITLTriggerEventError(event)
+ if (current := self._toolset_ids()) != toolset_ids:
+ raise ToolApprovalError(
+ f"The agent's toolsets changed while it waited for approval
(paused with {toolset_ids}, "
+ f"resumed with {current}), so the approved calls would reach a
connection the reviewer "
+ "did not see."
+ )
+
+ def _answers_waiting_request(self, event: dict[str, Any]) -> bool:
Review Comment:
Done, `_is_answer_for_waiting_request` in 8eae500733f.
##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -1435,6 +1613,184 @@ def _resume_after_tool_approval(
context, result, attempt_usage=subtract_run_usage(run_usage,
self._run_usage_base)
)
+ def _check_review_answer(self, event: dict[str, Any], toolset_ids:
list[str]) -> None:
+ """Refuse to resume when the review ended without an answer, or the
toolsets changed while it waited."""
+ if "error" in event:
+ if event.get("error_type") == "timeout":
+ raise HITLTimeoutError(f"Tool approval timed out:
{event['error']}")
+ raise HITLTriggerEventError(event)
+ if (current := self._toolset_ids()) != toolset_ids:
+ raise ToolApprovalError(
+ f"The agent's toolsets changed while it waited for approval
(paused with {toolset_ids}, "
+ f"resumed with {current}), so the approved calls would reach a
connection the reviewer "
+ "did not see."
+ )
+
+ def _answers_waiting_request(self, event: dict[str, Any]) -> bool:
+ """Whether the answer is for the approval request waiting now, not one
a form left open showed."""
+ if (answered := _answered_request(event)) is None or answered ==
self._approval_pause_number:
+ return True
+ # A review form left open on an earlier pause: its answer was about
other calls.
+ self.log.warning(
+ "An answer for approval request %s arrived while request %s was
waiting; "
+ "asking again instead of applying it.",
+ answered,
+ self._approval_pause_number,
+ )
+ return False
+
+ def _approval_from_answer(self, event: dict[str, Any]) -> bool |
ToolDenied:
Review Comment:
Done, `_build_approval_from_answer` in 8eae500733f.
##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -1435,6 +1613,184 @@ def _resume_after_tool_approval(
context, result, attempt_usage=subtract_run_usage(run_usage,
self._run_usage_base)
)
+ def _check_review_answer(self, event: dict[str, Any], toolset_ids:
list[str]) -> None:
+ """Refuse to resume when the review ended without an answer, or the
toolsets changed while it waited."""
+ if "error" in event:
+ if event.get("error_type") == "timeout":
+ raise HITLTimeoutError(f"Tool approval timed out:
{event['error']}")
+ raise HITLTriggerEventError(event)
+ if (current := self._toolset_ids()) != toolset_ids:
+ raise ToolApprovalError(
+ f"The agent's toolsets changed while it waited for approval
(paused with {toolset_ids}, "
+ f"resumed with {current}), so the approved calls would reach a
connection the reviewer "
+ "did not see."
+ )
+
+ def _answers_waiting_request(self, event: dict[str, Any]) -> bool:
+ """Whether the answer is for the approval request waiting now, not one
a form left open showed."""
+ if (answered := _answered_request(event)) is None or answered ==
self._approval_pause_number:
+ return True
+ # A review form left open on an earlier pause: its answer was about
other calls.
+ self.log.warning(
+ "An answer for approval request %s arrived while request %s was
waiting; "
+ "asking again instead of applying it.",
+ answered,
+ self._approval_pause_number,
+ )
+ return False
+
+ def _approval_from_answer(self, event: dict[str, Any]) -> bool |
ToolDenied:
+ """Turn the reviewer's answer into the result each paused call gets:
run it, or tell the agent why not."""
+ if event.get("timedout"):
+ # on_tool_approval_timeout="deny": nobody refused, so do not tell
the agent a person did.
+ self.log.info(
+ "Tool calls denied: nobody answered within
tool_approval_timeout=%s",
+ self.tool_approval_timeout,
+ )
+ return ToolDenied("No reviewer answered within the approval
timeout, so this call was not run.")
+ if LLMApprovalMixin.APPROVE in event["chosen_options"]:
+ self.log.info("Tool calls approved by %s",
LLMApprovalMixin._describe_responder(event))
+ return True
+ reason = (event.get("params_input") or {}).get("reason")
+ # Only a typed reason reaches the agent; an untouched field can come
back as None, "",
+ # or, from some UI versions, the whole parameter spec.
+ if not isinstance(reason, str) or not reason.strip():
+ reason = "A reviewer denied this tool call."
+ self.log.info("Tool calls rejected by %s",
LLMApprovalMixin._describe_responder(event))
+ return ToolDenied(reason)
+
+ def _load_tool_approval_answers(self, store: TaskStateStoreAccessor) ->
None:
+ """Read the answers reviewers gave this run, leaving out ones given
for other toolsets."""
+ stored =
_STORED_ANSWERS_ADAPTER.validate_python(store.get(_TOOL_APPROVAL_ANSWERS_KEY)
or [])
+ self._tool_approval_answers_stored = bool(stored)
+ toolset_ids = self._toolset_ids()
+ answers = [_ToolApprovalAnswer.load(raw) for raw in stored]
+ if self._new_tool_approval_answer is not None and answers:
+ # The resume stored it last; keep it as the object the warnings
look at.
+ answers[-1] = self._new_tool_approval_answer
+ # Like a resume, an answer never reaches a connection the reviewer did
not see.
+ self._tool_approval_answers = [answer for answer in answers if
answer.toolset_ids == toolset_ids]
+ if skipped := len(answers) - len(self._tool_approval_answers):
+ self.log.warning(
+ "Not applying %s approval answer(s) given while the agent had
other toolsets.",
+ skipped,
+ )
+
+ def _next_approval_step(self, requests: DeferredToolRequests) -> int |
None:
Review Comment:
Good catch, I could reproduce it: with `run_in_executor` + `asyncio.run` the
inner approval parked the task instead of failing it. Fixed in 8eae500733f. The
journal now tracks its running steps on the journal object itself, and
`_claim_next_approval_step` (renamed as well) asks it whether a tool call is
still running, so the refusal no longer depends on the context var. It only
looks at tool calls, since other journaled work like event handling can still
be running when the top-level approval is decided. The test is now parametrized
over a plain `await`, `asyncio.to_thread` and `loop.run_in_executor(None,
lambda: asyncio.run(...))`, and all three expect `UnsupportedToolDeferralError`.
##########
providers/common/ai/src/airflow/providers/common/ai/durable/capability.py:
##########
@@ -803,6 +819,20 @@ def _is_journaled_tool_payload(payload: object) -> bool:
return False
+def _capability_fingerprint(cache_key: tuple[object, ...]) -> str | None:
Review Comment:
Done, `_fingerprint_capability_operation` in 8eae500733f.
--
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]