Lee-W commented on code in PR #76916:
URL: https://github.com/apache/airflow/pull/76916#discussion_r4549392275


##########
providers/common/ai/docs/hitl_review.rst:
##########
@@ -152,6 +171,60 @@ navigate to
     :start-after: [START howto_operator_agent_hitl_review]
     :end-before: [END howto_operator_agent_hitl_review]
 
+.. _hitl-review-durable:
+
+With durable execution
+----------------------
+
+With ``durable=True`` (see :doc:`durable_execution`), the first output and 
every
+regeneration are agent runs in the task's journal, so a retry replays them 
instead of
+calling the model and the tools again (on Airflow 3.1 and 3.2, only the first 
output; see the
+end of this section). Each regeneration logs its own ``Durable:`` line in the 
task log, after
+the first run's.
+
+On Airflow 3.3 and later, each request for changes is also kept in the task 
state store
+beside the journal, with the round it was given in and a digest of the output 
the reviewer
+read. When
+the task fails after the review has started (a regeneration hits a failing 
tool or an
+unavailable model, or the review times out) the retry replays the first 
output, applies the

Review Comment:
   ```suggestion
   read. When the task fails after the review has started (a regeneration hits 
a failing tool 
   or an unavailable model, or the review times out) the retry replays the 
first output, applies the
   ```
   
   the breakline feels weird



##########
providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py:
##########
@@ -57,23 +133,56 @@ class HITLReviewProtocol(Protocol):
     @property
     def prompt(self) -> str | Sequence[Any]: ...
 
+    def regenerate_with_feedback(self, *, feedback: str, message_history: Any) 
-> tuple[str, Any]: ...
+
+    def dump_review_state(self, message_history: Any) -> str: ...
+
+    def load_review_state(self, context: Context, state: str) -> Any: ...
+
+    def complete_hitl_review(self, context: Context, approved_output: str, *, 
state: str | None) -> Any: ...
+
+    def _parks_between_rounds(self) -> bool: ...
+
+    def _recorded_review_feedback(self, context: Context, session: 
AgentSessionData) -> str | None: ...

Review Comment:
   ```suggestion
       def _get_recorded_review_feedback(self, context: Context, session: 
AgentSessionData) -> str | None: ...
   ```



##########
providers/common/ai/src/airflow/providers/common/ai/durable/journal.py:
##########
@@ -581,30 +592,116 @@ def pause(self) -> None:
         replay, this attempt has run live from there: what it recorded becomes 
trusted, and
         the earlier entries at positions it left empty (a call still waiting 
for approval, a
         tool that raised) are deleted. Positions from here on still run live 
after the pause.
+        Otherwise it only notes how far this attempt got, when that is further 
than any before.
         """
         self._load_meta()
         start = min((p for p in (self._diverge_from, self._diverged_at) if p 
is not None), default=None)
         if start is None or self._position <= start:
-            # This attempt has not run anything live past the untrusted 
entries yet.
+            # This attempt has not run anything live past the untrusted 
entries yet. Past a step
+            # it did not record (a tool set to run on every attempt), a later 
completion or
+            # cleanup cannot find how far it got, so note that and leave the 
rest as it was.
+            if self._position > self._high_water:
+                self._save_meta(
+                    self._diverge_from,
+                    model_at_fault=self._model_at_fault,
+                    paused=self._paused,
+                    tool_failure=self._tool_failure or None,
+                    last_model=self._noted_last_model,
+                )
+            return
+        survived = self._delete_unrecorded(range(start, self._position))
+        trusted_until = survived[0] if survived else self._position
+        self._diverge_from, self._model_at_fault = trusted_until, False
+        # A pause is not a failure, and the task resumes in the same attempt, 
so the count of
+        # attempts in a row a tool call failed carries over to a failure after 
the resume.
+        self._save_meta(
+            trusted_until,
+            model_at_fault=False,
+            paused=True,
+            tool_failure=self._tool_failure or None,
+            last_model=self._last_model_position,
+        )
+
+    def complete(self) -> None:

Review Comment:
   A killed attempt leaves no `high_water` note, so `_position < _high_water` 
is false and a non-diverging shorter completion writes nothing, keeping the 
killed attempt’s entries past its end. A journal-layer reproduction (attempt A 
killed after a refused write at position 2, B answers at position 2, then 
reject) has the retry return B’s rejected answer. Could `complete()` always 
write `last_model` and the end position when the journal is kept, or maybe we 
can document it as a limitation with a tracking issue and an 
`xfail(strict=True)` test?



##########
providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py:
##########
@@ -57,23 +133,56 @@ class HITLReviewProtocol(Protocol):
     @property
     def prompt(self) -> str | Sequence[Any]: ...
 
+    def regenerate_with_feedback(self, *, feedback: str, message_history: Any) 
-> tuple[str, Any]: ...
+
+    def dump_review_state(self, message_history: Any) -> str: ...
+
+    def load_review_state(self, context: Context, state: str) -> Any: ...
+
+    def complete_hitl_review(self, context: Context, approved_output: str, *, 
state: str | None) -> Any: ...
+
+    def _parks_between_rounds(self) -> bool: ...
+
+    def _recorded_review_feedback(self, context: Context, session: 
AgentSessionData) -> str | None: ...
+
+    def _record_review_feedback(self, context: Context, session: 
AgentSessionData, feedback: str) -> None: ...
+
+    def _start_review_round(
+        self, context: Context, session: AgentSessionData, *, message_history: 
Any, deadline: datetime | None
+    ) -> NoReturn: ...
+
+    def _park_for_review(
+        self, context: Context, session: AgentSessionData, *, deadline: 
datetime | None, state_sha256: str
+    ) -> NoReturn: ...
+
+    def _next_round(

Review Comment:
   ```suggestion
       def _regenerate_next_round(
   ```



##########
providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py:
##########
@@ -287,6 +603,53 @@ def regenerate_with_feedback(self, *, feedback: str, 
message_history: Any) -> tu
             "Operators using HITLReviewMixin must implement 
regenerate_with_feedback()."
         )
 
+    def dump_review_state(self, message_history: Any) -> str:
+        """
+        Serialize what :meth:`load_review_state` needs to regenerate after the 
task resumes.
+
+        Called on Airflow 3.3+ before every park; the string is kept in the 
task state store.
+
+        :param message_history: Provider-specific conversation state.
+        """
+        raise NotImplementedError("Operators using HITLReviewMixin must 
implement dump_review_state().")
+
+    def load_review_state(self, context: Context, state: str) -> Any:
+        """
+        Restore what :meth:`dump_review_state` saved, in the resumed task.
+
+        Called before each regeneration.
+
+        :returns: The ``message_history`` to pass to 
:meth:`regenerate_with_feedback`.
+        """
+        raise NotImplementedError("Operators using HITLReviewMixin must 
implement load_review_state().")
+
+    def _recorded_review_feedback(self, context: Context, session: 
AgentSessionData) -> str | None:

Review Comment:
   ```suggestion
       def _get_recorded_review_feedback(self, context: Context, session: 
AgentSessionData) -> str | None:
   ```



##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -1824,6 +1852,141 @@ def _emit_run_metadata(self, context: Context, result: 
Any, *, usage: RunUsage)
         if (model_name := getattr(result.response, "model_name", None)) is not 
None:
             ti.xcom_push(key=MODEL_NAME_XCOM_KEY, value=model_name)
 
+    def dump_review_state(self, message_history: Any) -> str:
+        """Save the conversation, and what the task returns if the output 
under review is approved."""
+        output = self._output_under_review
+        state: dict[str, Any] = {
+            "messages": ModelMessagesTypeAdapter.dump_python(message_history, 
mode="json")
+        }
+        output_json = dump_output_to_json(output)
+        if (
+            rehydrate_pydantic_output(self.output_type, output_json, 
serialize_output=False, is_edited=False)
+            == output
+        ):
+            state["output_json"] = output_json
+        else:
+            # Reading the JSON back would give a different value: an output 
function's or an output
+            # marker's result stays text, and "42" under ``str | int`` becomes 
42. Save the result instead.
+            state["result"] = 
to_jsonable_python(self._convert_output_for_xcom(output))
+        return json.dumps(state)
+
+    def load_review_state(self, context: Context, state: str) -> Any:
+        """Restore the conversation and the usage budget before regenerating 
in the resumed task."""
+        ti = context["task_instance"]
+        # The regeneration shares the budget the paused rounds spent, as it 
does without a pause.
+        self._usage_budget = self._build_usage_budget(context, 
coerce_usage_limits(self.usage_limits), ti=ti)
+        self._run_usage = self._usage_budget.load() if self._usage_budget else 
None
+        self._run_identity_attrs = build_run_identity_attributes(ti)
+        return 
ModelMessagesTypeAdapter.validate_python(json.loads(state)["messages"])
+
+    def complete_hitl_review(self, context: Context, approved_output: str, *, 
state: str | None) -> Any:
+        """Return the approved output as the agent returned it; 
``approved_output`` is only its display text."""
+        # The reviewer can approve, reject, or ask for a new answer, but 
cannot edit the answer text.
+        # So the approved answer is the last answer the agent returned.
+        if state is None:
+            result = self._convert_output_for_xcom(self._output_under_review)
+        elif "result" in (saved := json.loads(state)):
+            result = saved["result"]
+        else:
+            result = self._convert_output_for_xcom(
+                rehydrate_pydantic_output(
+                    self.output_type, saved["output_json"], 
serialize_output=False, is_edited=False
+                )
+            )
+        if self._durable_journal is not None:
+            # The approved output is the task's result, so nothing replays the 
review again.
+            self._durable_journal.cleanup()
+            self._delete_review_answers(context)
+        self._clear_usage_budget(context)
+        return result
+
+    def resume_hitl_review(
+        self,
+        context: Context,
+        iteration: int,
+        deadline: str | None,
+        state_sha256: str,
+        event: dict[str, Any],
+    ) -> Any:
+        if self.durable:
+            # The regeneration after round ``iteration`` is the journal's run 
``iteration``, in this
+            # process as on a retry that replays the rounds before it; run 0 
made the first output.
+            self._durable_journal = 
DurableJournal(self._build_durable_storage(context), first_run=iteration)
+        with self._durable_review(context):
+            return super().resume_hitl_review(context, iteration, deadline, 
state_sha256, event)
+
+    @contextmanager
+    def _durable_review(self, context: Context) -> Iterator[None]:
+        """Under durable, make a review that ends in a reject or 
``max_hitl_iterations`` start over on retry."""
+        try:
+            yield
+        except (HITLRejectException, HITLMaxIterationsError):
+            if (journal := self._durable_journal) is not None:
+                # Replaying would show the reviewer the outputs of this review 
again. The retry asks
+                # the model again for the first output, replaying the tool 
calls before it, and
+                # regenerates from there.
+                self.log.info(
+                    "The review ended without an approved output; a retry asks 
the model again for "
+                    "the first output and starts the review over."
+                )
+                journal.ask_model_again(0)
+                self._delete_review_answers(context)
+            raise
+
+    @staticmethod
+    def _stored_review_answers(context: Context) -> list[_ReviewAnswer]:

Review Comment:
   ```suggestion
       def _load_review_answers(context: Context) -> list[_ReviewAnswer]:
   ```



##########
providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py:
##########
@@ -144,15 +275,218 @@ def run_hitl_review(
         deadline = time.monotonic() + self.hitl_timeout.total_seconds() if 
self.hitl_timeout else None
 
         return self._poll_loop(  # type: ignore[attr-defined]
+            context=context,
             ti=ti,
             session=session,
             message_history=message_history,
             deadline=deadline,
         )
 
+    def _start_review_round(
+        self: HITLReviewProtocol,
+        context: Context,
+        session: AgentSessionData,
+        *,
+        message_history: Any,
+        deadline: datetime | None,
+    ) -> NoReturn:
+        """
+        Save the conversation, show ``session`` to the reviewer, and park 
until they act.
+
+        A round the reviewer already asked to change in an earlier attempt of 
the task (see
+        :meth:`_recorded_review_feedback`) is not shown again: its feedback is 
applied, and the
+        next round starts.
+        """
+        ti = context["task_instance"]
+        while session.iteration < self.max_hitl_iterations and (
+            feedback := self._recorded_review_feedback(context, session)
+        ):
+            self.log.info(
+                "Applying the feedback given on iteration %d in an earlier 
attempt: %s",
+                session.iteration,
+                feedback,
+            )
+            # The HITL Review tab shows each round from these.
+            ti.xcom_push(key=f"{XCOM_AGENT_OUTPUT_PREFIX}{session.iteration}", 
value=session.current_output)
+            
ti.xcom_push(key=f"{XCOM_HUMAN_FEEDBACK_PREFIX}{session.iteration}", 
value=feedback)
+            session, message_history = self._next_round(session, feedback, 
message_history)
+        state = self.dump_review_state(message_history)
+        retention = (
+            deadline - utcnow() + _REVIEW_STATE_RETENTION_MARGIN if deadline 
is not None else NEVER_EXPIRE
+        )
+        context["task_state_store"].set(_REVIEW_STATE_KEY, state, 
retention=retention)
+        self._park_for_review(
+            context, session, deadline=deadline, 
state_sha256=hashlib.sha256(state.encode()).hexdigest()
+        )
+
+    def _next_round(

Review Comment:
   ```suggestion
       def _regenerate_next_round(
   ```



##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -1824,6 +1852,141 @@ def _emit_run_metadata(self, context: Context, result: 
Any, *, usage: RunUsage)
         if (model_name := getattr(result.response, "model_name", None)) is not 
None:
             ti.xcom_push(key=MODEL_NAME_XCOM_KEY, value=model_name)
 
+    def dump_review_state(self, message_history: Any) -> str:
+        """Save the conversation, and what the task returns if the output 
under review is approved."""
+        output = self._output_under_review
+        state: dict[str, Any] = {
+            "messages": ModelMessagesTypeAdapter.dump_python(message_history, 
mode="json")
+        }
+        output_json = dump_output_to_json(output)
+        if (
+            rehydrate_pydantic_output(self.output_type, output_json, 
serialize_output=False, is_edited=False)
+            == output
+        ):
+            state["output_json"] = output_json
+        else:
+            # Reading the JSON back would give a different value: an output 
function's or an output
+            # marker's result stays text, and "42" under ``str | int`` becomes 
42. Save the result instead.
+            state["result"] = 
to_jsonable_python(self._convert_output_for_xcom(output))
+        return json.dumps(state)
+
+    def load_review_state(self, context: Context, state: str) -> Any:
+        """Restore the conversation and the usage budget before regenerating 
in the resumed task."""
+        ti = context["task_instance"]
+        # The regeneration shares the budget the paused rounds spent, as it 
does without a pause.
+        self._usage_budget = self._build_usage_budget(context, 
coerce_usage_limits(self.usage_limits), ti=ti)
+        self._run_usage = self._usage_budget.load() if self._usage_budget else 
None
+        self._run_identity_attrs = build_run_identity_attributes(ti)
+        return 
ModelMessagesTypeAdapter.validate_python(json.loads(state)["messages"])
+
+    def complete_hitl_review(self, context: Context, approved_output: str, *, 
state: str | None) -> Any:
+        """Return the approved output as the agent returned it; 
``approved_output`` is only its display text."""
+        # The reviewer can approve, reject, or ask for a new answer, but 
cannot edit the answer text.
+        # So the approved answer is the last answer the agent returned.
+        if state is None:
+            result = self._convert_output_for_xcom(self._output_under_review)
+        elif "result" in (saved := json.loads(state)):
+            result = saved["result"]
+        else:
+            result = self._convert_output_for_xcom(
+                rehydrate_pydantic_output(
+                    self.output_type, saved["output_json"], 
serialize_output=False, is_edited=False
+                )
+            )
+        if self._durable_journal is not None:
+            # The approved output is the task's result, so nothing replays the 
review again.
+            self._durable_journal.cleanup()
+            self._delete_review_answers(context)
+        self._clear_usage_budget(context)
+        return result
+
+    def resume_hitl_review(
+        self,
+        context: Context,
+        iteration: int,
+        deadline: str | None,
+        state_sha256: str,
+        event: dict[str, Any],
+    ) -> Any:
+        if self.durable:
+            # The regeneration after round ``iteration`` is the journal's run 
``iteration``, in this
+            # process as on a retry that replays the rounds before it; run 0 
made the first output.
+            self._durable_journal = 
DurableJournal(self._build_durable_storage(context), first_run=iteration)
+        with self._durable_review(context):
+            return super().resume_hitl_review(context, iteration, deadline, 
state_sha256, event)
+
+    @contextmanager
+    def _durable_review(self, context: Context) -> Iterator[None]:
+        """Under durable, make a review that ends in a reject or 
``max_hitl_iterations`` start over on retry."""
+        try:
+            yield
+        except (HITLRejectException, HITLMaxIterationsError):
+            if (journal := self._durable_journal) is not None:
+                # Replaying would show the reviewer the outputs of this review 
again. The retry asks
+                # the model again for the first output, replaying the tool 
calls before it, and
+                # regenerates from there.
+                self.log.info(
+                    "The review ended without an approved output; a retry asks 
the model again for "
+                    "the first output and starts the review over."
+                )
+                journal.ask_model_again(0)
+                self._delete_review_answers(context)
+            raise
+
+    @staticmethod
+    def _stored_review_answers(context: Context) -> list[_ReviewAnswer]:
+        return _REVIEW_ANSWERS_ADAPTER.validate_python(
+            context["task_state_store"].get(_HITL_REVIEW_ANSWERS_KEY) or []
+        )
+
+    def _recorded_review_feedback(self, context: Context, session: 
AgentSessionData) -> str | None:

Review Comment:
   ```suggestion
       def _get_recorded_review_feedback(self, context: Context, session: 
AgentSessionData) -> str | None:
   ```



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