pankajkoti commented on code in PR #75358:
URL: https://github.com/apache/airflow/pull/75358#discussion_r4352252218


##########
providers/standard/src/airflow/providers/standard/version_compat.py:
##########
@@ -37,6 +37,7 @@ def get_base_airflow_version_tuple() -> tuple[int, int, int]:
 AIRFLOW_V_3_1_3_PLUS: bool = get_base_airflow_version_tuple() >= (3, 1, 3)
 AIRFLOW_V_3_2_PLUS: bool = get_base_airflow_version_tuple() >= (3, 2, 0)
 AIRFLOW_V_3_3_PLUS: bool = get_base_airflow_version_tuple() >= (3, 3, 0)
+AIRFLOW_V_3_4_PLUS: bool = get_base_airflow_version_tuple() >= (3, 4, 0)

Review Comment:
   Small question on the gate. `get_base_airflow_version_tuple()` drops the 
pre-release part, so 3.4.0b1 counts as 3.4+. From the tags I checked, 3.4.0b1 
has the upsert rewrite but not `request_created_at` (b2 has both), so the docs 
line about a replaced request being refused would not hold there. Is a beta a 
concern for you, or fine as is? Totally your call.



##########
providers/standard/src/airflow/providers/standard/operators/hitl.py:
##########
@@ -50,11 +66,165 @@
     from airflow.sdk.exceptions import TaskAwaitingInput
 
 if TYPE_CHECKING:
+    from datetime import datetime
+
     from airflow.providers.common.compat.sdk import Context
     from airflow.sdk.execution_time.hitl import HITLUser
     from airflow.sdk.types import RuntimeTaskInstanceProtocol
 
 
+def _request_input_and_wait(
+    task: BaseOperator,
+    context: Context,
+    *,
+    options: list[str],
+    subject: str,
+    body: str | None,
+    defaults: list[str] | None,
+    multiple: bool,
+    params: dict[str, Any],
+    assigned_users: list[HITLUser] | None,
+    notifiers: Sequence[BaseNotifier],
+    timeout: timedelta | None,
+    timeout_datetime: datetime | None,
+    method_name: str,
+    kwargs: dict[str, Any] | None = None,
+) -> NoReturn:
+    """Write the Human-in-the-loop request, notify, and stop the task until a 
response arrives."""
+    ti_id = context["task_instance"].id
+    upsert_hitl_detail(
+        ti_id=ti_id,
+        options=options,
+        subject=subject,
+        body=body,
+        defaults=defaults,
+        multiple=multiple,
+        params=params,
+        assigned_users=assigned_users,
+    )
+
+    task.log.info("Waiting for response")
+    for notifier in notifiers:
+        notifier(context)
+
+    if AIRFLOW_V_3_3_PLUS:
+        # Airflow 3.3+: park the task in AWAITING_INPUT -- no trigger, no 
triggerer. The task
+        # is resumed by the Core API response handler or the scheduler timeout 
sweep, so the
+        # triggerer no longer needs to run for Human-in-the-loop tasks to make 
progress.
+        raise TaskAwaitingInput(method_name=method_name, kwargs=kwargs, 
timeout=timeout)
+
+    # Fallback for Airflow versions < 3.3: defer the response check to 
HITLTrigger on the triggerer.
+    task.defer(
+        trigger=HITLTrigger(
+            ti_id=ti_id,
+            options=options,
+            defaults=defaults,
+            params=params,
+            multiple=multiple,
+            timeout_datetime=timeout_datetime,
+        ),
+        method_name=method_name,
+        kwargs=kwargs,
+    )
+
+
+def request_input(
+    *,
+    subject: str,
+    options: list[str],
+    body: str | None = None,
+    defaults: str | list[str] | None = None,
+    multiple: bool = False,
+    params: ParamsDict | dict[str, Any] | None = None,
+    notifiers: Sequence[BaseNotifier] | BaseNotifier | None = None,
+    assigned_users: HITLUser | list[HITLUser] | None = None,
+    timeout: timedelta | None = None,
+    key: str | None = None,
+) -> InputResponse:
+    """
+    Ask a person for input from inside a running Python task, and return their 
answer.
+
+    The first time a question is asked, the request appears on the Required 
Actions page, the same
+    as one from :class:`HITLOperator`, and the task stops there: it waits in 
the ``awaiting_input``
+    state without holding a worker slot. Once the request is answered, the 
task runs again from the
+    start of its callable, and this call returns the answer instead of asking 
again. Save anything
+    the second run should not redo (for example an agent framework's run 
state) to
+    ``task_state_store`` before calling this.
+
+    It works in ``@task`` and ``PythonOperator`` callables (sync or async), 
including ``@task.branch``
+    and ``@task.short_circuit``. It can't pause tasks that run their callable 
in another process, such
+    as ``@task.virtualenv``, ``@task.external_python``, ``@task.docker`` or 
``@task.kubernetes``.
+
+    Needs Airflow 3.4+. Earlier versions keep the first request's subject, 
body and options when a
+    task asks again, after a resume or on a retry, so the reviewer would 
answer a question they were
+    not shown.
+
+    :param subject: Headline shown to the reviewer.
+    :param options: Options the reviewer chooses from.
+    :param body: Details the reviewer needs to decide (Markdown).
+    :param defaults: Options applied if ``timeout`` passes without an answer. 
Without defaults, the
+        task fails with 
:class:`~airflow.providers.standard.exceptions.HITLTimeoutError` at the timeout.
+    :param multiple: Whether the reviewer can choose more than one option.
+    :param params: Form fields for the reviewer to fill in, defined like Dag 
params.
+    :param notifiers: Notifiers called with the task context once the request 
is written.
+    :param assigned_users: Users allowed to answer. Anyone with access can 
answer if not set.
+    :param timeout: How long to wait for an answer. Waits indefinitely if not 
set.
+    :param key: Which question this is, so a resumed task gets each answer 
back at the right call.
+        Defaults to ``subject``. Each key is asked at most once per try. Set 
it when the subject can
+        change between runs, or when the task can ask the same subject more 
than once, for example
+        one approval per agent tool call (use the call's id).
+    """
+    if not AIRFLOW_V_3_4_PLUS:
+        raise AirflowOptionalProviderFeatureException("request_input() needs 
Airflow 3.4+.")
+    context = get_current_context()
+    task = context["task"]
+    if not isinstance(task, PythonOperator) or isinstance(task, 
_BasePythonVirtualenvOperator):
+        raise TypeError(
+            f"request_input() was called from {type(task).__name__}, which 
cannot resume it. It works "
+            "only in tasks that run their Python callable in the task's own 
process, such as @task "
+            "and PythonOperator."
+        )
+    defaults = [defaults] if isinstance(defaults, str) else defaults
+    params = params if isinstance(params, ParamsDict) else ParamsDict(params 
or {})
+    validate_options(options)
+    validate_params(params)
+    validate_defaults(defaults, options, multiple)
+    key = subject if key is None else key
+    answer_log = answer_log_var.get()
+    if answer_log is not None and (answer := answer_log.take(key)) is not None:
+        # The code may have changed since the reviewer answered; never hand 
back an option it no
+        # longer offers.
+        validate_chosen_options(answer["chosen_options"], options)
+        return answer
+    serialized_params = {name: params.get_param(name).serialize() for name in 
params}

Review Comment:
   A required field with no default, for example `params={"x": 
Param(schema={"type": "string"})}`, serializes with `value: NOTSET`. The task 
parks fine, but on resume `serde.deserialize` raises `TypeError` and the 
fallback raises `KeyError`, so the task fails after the reviewer has already 
answered. I reproduced this at the serde layer only, so I might be misreading 
how the task runner calls it. `HITLOperator` doesn't hit it because it passes 
`kwargs=None`. Would rebuilding the params from the original call or the 
HITLDetail row work here? If you'd rather leave it for a follow-up, a clear 
error at call time for a param with no default would also help.



##########
providers/standard/src/airflow/providers/standard/operators/hitl.py:
##########
@@ -50,11 +66,165 @@
     from airflow.sdk.exceptions import TaskAwaitingInput
 
 if TYPE_CHECKING:
+    from datetime import datetime
+
     from airflow.providers.common.compat.sdk import Context
     from airflow.sdk.execution_time.hitl import HITLUser
     from airflow.sdk.types import RuntimeTaskInstanceProtocol
 
 
+def _request_input_and_wait(
+    task: BaseOperator,
+    context: Context,
+    *,
+    options: list[str],
+    subject: str,
+    body: str | None,
+    defaults: list[str] | None,
+    multiple: bool,
+    params: dict[str, Any],
+    assigned_users: list[HITLUser] | None,
+    notifiers: Sequence[BaseNotifier],
+    timeout: timedelta | None,
+    timeout_datetime: datetime | None,
+    method_name: str,
+    kwargs: dict[str, Any] | None = None,
+) -> NoReturn:
+    """Write the Human-in-the-loop request, notify, and stop the task until a 
response arrives."""
+    ti_id = context["task_instance"].id
+    upsert_hitl_detail(
+        ti_id=ti_id,
+        options=options,
+        subject=subject,
+        body=body,
+        defaults=defaults,
+        multiple=multiple,
+        params=params,
+        assigned_users=assigned_users,
+    )
+
+    task.log.info("Waiting for response")
+    for notifier in notifiers:
+        notifier(context)
+
+    if AIRFLOW_V_3_3_PLUS:
+        # Airflow 3.3+: park the task in AWAITING_INPUT -- no trigger, no 
triggerer. The task
+        # is resumed by the Core API response handler or the scheduler timeout 
sweep, so the
+        # triggerer no longer needs to run for Human-in-the-loop tasks to make 
progress.
+        raise TaskAwaitingInput(method_name=method_name, kwargs=kwargs, 
timeout=timeout)
+
+    # Fallback for Airflow versions < 3.3: defer the response check to 
HITLTrigger on the triggerer.
+    task.defer(
+        trigger=HITLTrigger(
+            ti_id=ti_id,
+            options=options,
+            defaults=defaults,
+            params=params,
+            multiple=multiple,
+            timeout_datetime=timeout_datetime,
+        ),
+        method_name=method_name,
+        kwargs=kwargs,
+    )
+
+
+def request_input(
+    *,
+    subject: str,
+    options: list[str],
+    body: str | None = None,
+    defaults: str | list[str] | None = None,
+    multiple: bool = False,
+    params: ParamsDict | dict[str, Any] | None = None,
+    notifiers: Sequence[BaseNotifier] | BaseNotifier | None = None,
+    assigned_users: HITLUser | list[HITLUser] | None = None,
+    timeout: timedelta | None = None,
+    key: str | None = None,
+) -> InputResponse:
+    """
+    Ask a person for input from inside a running Python task, and return their 
answer.
+
+    The first time a question is asked, the request appears on the Required 
Actions page, the same
+    as one from :class:`HITLOperator`, and the task stops there: it waits in 
the ``awaiting_input``
+    state without holding a worker slot. Once the request is answered, the 
task runs again from the
+    start of its callable, and this call returns the answer instead of asking 
again. Save anything
+    the second run should not redo (for example an agent framework's run 
state) to
+    ``task_state_store`` before calling this.
+
+    It works in ``@task`` and ``PythonOperator`` callables (sync or async), 
including ``@task.branch``
+    and ``@task.short_circuit``. It can't pause tasks that run their callable 
in another process, such
+    as ``@task.virtualenv``, ``@task.external_python``, ``@task.docker`` or 
``@task.kubernetes``.
+
+    Needs Airflow 3.4+. Earlier versions keep the first request's subject, 
body and options when a
+    task asks again, after a resume or on a retry, so the reviewer would 
answer a question they were
+    not shown.
+
+    :param subject: Headline shown to the reviewer.
+    :param options: Options the reviewer chooses from.
+    :param body: Details the reviewer needs to decide (Markdown).
+    :param defaults: Options applied if ``timeout`` passes without an answer. 
Without defaults, the
+        task fails with 
:class:`~airflow.providers.standard.exceptions.HITLTimeoutError` at the timeout.
+    :param multiple: Whether the reviewer can choose more than one option.
+    :param params: Form fields for the reviewer to fill in, defined like Dag 
params.
+    :param notifiers: Notifiers called with the task context once the request 
is written.
+    :param assigned_users: Users allowed to answer. Anyone with access can 
answer if not set.
+    :param timeout: How long to wait for an answer. Waits indefinitely if not 
set.
+    :param key: Which question this is, so a resumed task gets each answer 
back at the right call.
+        Defaults to ``subject``. Each key is asked at most once per try. Set 
it when the subject can
+        change between runs, or when the task can ask the same subject more 
than once, for example
+        one approval per agent tool call (use the call's id).
+    """
+    if not AIRFLOW_V_3_4_PLUS:
+        raise AirflowOptionalProviderFeatureException("request_input() needs 
Airflow 3.4+.")
+    context = get_current_context()
+    task = context["task"]
+    if not isinstance(task, PythonOperator) or isinstance(task, 
_BasePythonVirtualenvOperator):
+        raise TypeError(
+            f"request_input() was called from {type(task).__name__}, which 
cannot resume it. It works "
+            "only in tasks that run their Python callable in the task's own 
process, such as @task "
+            "and PythonOperator."
+        )
+    defaults = [defaults] if isinstance(defaults, str) else defaults
+    params = params if isinstance(params, ParamsDict) else ParamsDict(params 
or {})
+    validate_options(options)
+    validate_params(params)
+    validate_defaults(defaults, options, multiple)
+    key = subject if key is None else key
+    answer_log = answer_log_var.get()

Review Comment:
   I tried `ThreadPoolExecutor.submit`, parallel `asyncio.gather`, and 
`asyncio.TaskGroup` around `request_input`, outside the real task runner. With 
`submit` the thread has an empty context, so a resumed call looks like a first 
ask and parks again. With `gather` the single HITLDetail row ends up with the 
last question's subject while the parked key is the first. With `TaskGroup` the 
park arrives as a `BaseExceptionGroup`, so the task fails. The docs say "sync 
or async", so would a short caveat make sense? Also, the key-mismatch guards 
raise `ValueError` and `RuntimeError`, so a wrapper that turns exceptions into 
tool-error text can swallow them. Is "the task fails" in the docs meant to 
cover that?



##########
providers/standard/src/airflow/providers/standard/operators/hitl.py:
##########
@@ -25,19 +25,35 @@
     AIRFLOW_V_3_1_3_PLUS,
     AIRFLOW_V_3_1_PLUS,
     AIRFLOW_V_3_3_PLUS,
+    AIRFLOW_V_3_4_PLUS,
 )
 
 if not AIRFLOW_V_3_1_PLUS:
     raise AirflowOptionalProviderFeatureException("Human in the loop 
functionality needs Airflow 3.1+.")
 
 from collections.abc import Collection, Mapping, Sequence
 from datetime import timedelta
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, NoReturn
 from urllib.parse import ParseResult, urlencode, urlparse, urlunparse
 
-from airflow.providers.common.compat.sdk import BranchMixIn, SkipMixin, conf
-from airflow.providers.standard.exceptions import HITLRejectException, 
HITLTimeoutError, HITLTriggerEventError
+from airflow.providers.common.compat.sdk import BranchMixIn, SkipMixin, conf, 
get_current_context
+from airflow.providers.standard.exceptions import (  # noqa: F401 - kept 
importable from this module

Review Comment:
   Minor. `HITLTimeoutError` and `HITLTriggerEventError` are unused in this 
module now, and I found no in-repo importers. `HITLRejectException` is still 
used. Would you prefer to drop the `noqa` re-export, or keep it for third-party 
code with a short note?



##########
providers/standard/docs/operators/hitl.rst:
##########
@@ -42,3 +42,69 @@ For an end-to-end guide on using HITL operators, including 
examples and
 recommended patterns, see the HITL tutorial:
 
 * `HITL tutorial 
<https://airflow.apache.org/docs/apache-airflow/stable/tutorial/hitl.html>`_
+
+.. _howto/operator:request_input:
+
+Asking for input from inside a Python task
+------------------------------------------
+
+Sometimes the question only comes up halfway through a task. An agent built 
with the OpenAI
+Agents SDK, Strands or Google ADK, for example, stops in front of a tool call 
and needs a person
+to approve it. 
:func:`~airflow.providers.standard.operators.hitl.request_input` asks the 
question
+and returns the answer:
+
+.. exampleinclude:: 
/../../standard/src/airflow/providers/standard/example_dags/example_hitl_request_input.py
+    :language: python
+    :start-after: [START howto_hitl_request_input]
+    :end-before: [END howto_hitl_request_input]
+
+The first time the task reaches ``request_input()``, the question appears on 
the Required Actions
+page and the task stops there. It waits in the ``awaiting_input`` state 
without holding a worker
+slot. When the reviewer answers, the task runs again from the start of its 
callable, and this
+time the same call returns the answer: a dict with ``chosen_options``, 
``params_input``,
+``responded_at`` (a ``datetime``), ``responded_by_user`` and ``timedout``.
+
+``request_input()`` takes the same request fields as
+:class:`~airflow.providers.standard.operators.hitl.HITLOperator` (``subject``, 
``options``, ``body``,
+``defaults``, ``multiple``, ``params``, ``notifiers``, ``assigned_users``), a 
``timeout``, and a
+``key``. A task can ask several questions; each run returns every answer it 
already has and stops
+at the first question that has none.
+
+Things to know:
+
+* Code before ``request_input()`` runs again on every run. Keep anything 
expensive, such as an
+  agent framework's saved run state, in ``task_state_store`` so the next run 
restores it instead of
+  redoing it.
+* Each answer goes back to the call with the same ``key``, which defaults to 
``subject``, and each
+  key is asked at most once per try. Pass ``key`` when the subject can change 
between runs (for
+  example when it includes a value computed fresh each run), or when the task 
can ask the same
+  subject more than once, such as one approval per agent tool call: key on the 
call's id.
+* The task fails instead of guessing when the keys don't line up: when it asks 
a key whose answer
+  it has already read, and when a run that resumed with an answer asks a new 
question, or finishes,
+  before reading that answer.
+* Every pause carries the answers the task has received so far in this try, 
including their
+  ``params_input``, so keep the number of questions per try small (a handful, 
not hundreds) and the
+  form fields short.
+* A side effect between two questions, such as issuing a refund before asking 
whether to email the
+  customer, runs again when the task resumes after the second answer. Record 
it in
+  ``task_state_store`` and skip it when the record is there:
+
+  .. code-block:: python
+
+      if task_state_store.get("refund_id") is None:
+          task_state_store.set("refund_id", issue_refund(order_id))
+
+* A retry asks every question again, but ``task_state_store`` keeps its values 
across retries, so
+  the retry still sees what the failed try saved. Delete those values when you 
no longer need them.
+* Without ``defaults``, the task fails with
+  :class:`~airflow.providers.standard.exceptions.HITLTimeoutError` when 
``timeout`` passes. With
+  ``defaults``, it resumes with ``timedout`` set to ``True`` and 
``responded_by_user`` set to
+  ``None``.
+* It works in ``@task``, ``@task.branch``, ``@task.short_circuit`` and 
``PythonOperator`` callables,
+  sync or async. It can't pause tasks that run their callable in another 
process
+  (``@task.virtualenv``, ``@task.external_python``, ``@task.docker``, 
``@task.kubernetes``).
+* It needs Airflow 3.4+.
+* The Required Actions page refuses an answer to a request that the task has 
since replaced. A
+  client answering through the REST API gets the same protection only if it 
sends the

Review Comment:
   Minor wording. The GET field is `created_at` and `request_created_at` is the 
name on the answering call. "The request's `created_at`" may read clearer.



##########
providers/standard/src/airflow/providers/standard/operators/python.py:
##########
@@ -236,6 +245,43 @@ def __prepare_execution() -> 
tuple[ExecutionCallableRunner, OutletEventAccessors
 
         return return_value
 
+    def resume_with_input(
+        self,
+        context: Context,
+        event: dict[str, Any],
+        key: str,
+        options: list[str],
+        params: dict[str, dict[str, Any]],
+        answered: list[list[Any]],
+    ) -> Any:
+        """
+        Run the task again once the input it asked for with 
``request_input()`` arrives.
+
+        The callable runs from the top. Each ``request_input()`` call that was 
answered, in this or
+        an earlier pause, returns its answer instead of asking again, and the 
run fails if it never
+        reads the answer it resumed with. A request that timed out without 
defaults raises
+        :class:`~airflow.providers.standard.exceptions.HITLTimeoutError`.
+        """
+        if "error" in event:
+            raise_for_error_event(event)
+        validate_chosen_options(event["chosen_options"], options)
+        validate_params_input(event["params_input"], 
params_from_serialized(params))

Review Comment:
   Minor. `execute_complete` uses `event["params_input"] or {}` and this path 
indexes it directly. Core sends `{}` today, so I don't think it fails now, but 
mirroring `or {}` would keep the two consistent.



-- 
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