Airflow Summit 2026 is coming August 31 - September 2 in Austin, TX. Register now to secure your spot!

Source code for airflow.providers.common.ai.plugins.hitl_review

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

from __future__ import annotations

from typing import Annotated, Any
from urllib.parse import urlparse

from airflow.plugins_manager import AirflowPlugin
from airflow.providers.common.compat.sdk import conf
from airflow.providers.common.compat.version_compat import AIRFLOW_V_3_1_PLUS

_PLUGIN_PREFIX = "/hitl-review"


def _get_base_url_path(path: str) -> str:
    """Construct URL path with webserver base_url prefix for non-root deployments."""
    base_url = conf.get("api", "base_url", fallback="/")
    if base_url.startswith(("http://", "https://")):
        base_path = urlparse(base_url).path
    else:
        base_path = base_url
    base_path = base_path.rstrip("/")
    return base_path + path


def _get_bundle_url() -> str:
    """
    Return bundle URL for the React plugin.

    Uses an absolute URL when api.base_url is a full URL so the bundle loads
    correctly in Vite dev mode, where import() resolves relative to the script
    origin (5173) rather than the document origin (28080).
    """
    path = _get_base_url_path(f"{_PLUGIN_PREFIX}/static/main.umd.cjs")
    base_url = conf.get("api", "base_url", fallback="/")
    if base_url.startswith(("http://", "https://")):
        parsed = urlparse(base_url)
        return f"{parsed.scheme}://{parsed.netloc}" + path
    return path


if AIRFLOW_V_3_1_PLUS:
    import mimetypes
    from pathlib import Path

    from fastapi import Depends, FastAPI, HTTPException, Query
    from fastapi.staticfiles import StaticFiles
    from sqlalchemy import delete, select
    from sqlalchemy.orm import Session

    from airflow.api_fastapi.auth.managers.models.resource_details import DagAccessEntity
    from airflow.api_fastapi.core_api.security import requires_access_dag
    from airflow.models.dagrun import DagRun
    from airflow.models.taskinstance import TaskInstance as TI
    from airflow.models.xcom import XComModel
    from airflow.providers.common.ai.utils.hitl_review import (
        XCOM_AGENT_OUTPUT_PREFIX,
        XCOM_AGENT_SESSION,
        XCOM_HUMAN_ACTION,
        XCOM_HUMAN_FEEDBACK_PREFIX,
        AgentSessionData,
        HITLReviewResponse,
        HumanActionData,
        HumanFeedbackRequest,
        SessionStatus,
    )
    from airflow.utils.session import create_session
    from airflow.utils.state import TaskInstanceState

    def _get_session():
        with create_session(scoped=False) as session:
            yield session

    SessionDep = Annotated[Session, Depends(_get_session)]

    def _read_xcom(
        session: Session, *, dag_id: str, run_id: str, task_id: str, map_index: int = -1, key: str
    ):
        """Read a single XCom value from the database."""
        row = session.scalars(
            XComModel.get_many(
                run_id=run_id,
                key=key,
                dag_ids=dag_id,
                task_ids=task_id,
                map_indexes=map_index,
                limit=1,
            )
        ).first()
        if row is None:
            return None
        return row.value

    def _read_xcom_by_prefix(
        session: Session, *, dag_id: str, run_id: str, task_id: str, map_index: int = -1, prefix: str
    ) -> dict[int, Any]:
        """Read all iteration-keyed XCom entries matching *prefix* (e.g. ``airflow_hitl_review_agent_output_``)."""
        query = select(XComModel.key, XComModel.value).where(
            XComModel.dag_id == dag_id,
            XComModel.run_id == run_id,
            XComModel.task_id == task_id,
            XComModel.map_index == map_index,
            XComModel.key.like(f"{prefix}%"),
        )
        result: dict[int, Any] = {}
        for key, value in session.execute(query).all():
            suffix = key[len(prefix) :]
            if suffix.isdigit():
                result[int(suffix)] = value
        return result

    def _write_xcom(
        session: Session, *, dag_id: str, run_id: str, task_id: str, map_index: int = -1, key: str, value
    ):
        """Write data to db."""
        # Stores value natively to match worker-written XComs; use XComModel.set(serialize=False) once min Airflow >= 3.2.
        dag_run_id = session.scalar(select(DagRun.id).where(DagRun.dag_id == dag_id, DagRun.run_id == run_id))
        if dag_run_id is None:
            raise HTTPException(404, f"DAG run not found on DAG {dag_id!r} with ID {run_id!r}")
        session.execute(
            delete(XComModel).where(
                XComModel.key == key,
                XComModel.run_id == run_id,
                XComModel.task_id == task_id,
                XComModel.dag_id == dag_id,
                XComModel.map_index == map_index,
            )
        )
        session.add(
            XComModel(
                dag_run_id=dag_run_id,
                key=key,
                value=value,
                run_id=run_id,
                task_id=task_id,
                dag_id=dag_id,
                map_index=map_index,
            )
        )
        session.flush()

    _RUNNING_TI_STATES = frozenset(
        {
            TaskInstanceState.RUNNING,
            TaskInstanceState.DEFERRED,
            TaskInstanceState.UP_FOR_RETRY,
            TaskInstanceState.QUEUED,
            TaskInstanceState.SCHEDULED,
        }
    )

    def _is_task_completed(
        session: Session, *, dag_id: str, run_id: str, task_id: str, map_index: int = -1
    ) -> bool:
        """Return True if the task instance is no longer running."""
        state = session.scalar(
            select(TI.state).where(
                TI.dag_id == dag_id,
                TI.run_id == run_id,
                TI.task_id == task_id,
                TI.map_index == map_index,
            )
        )
        if state is None:
            return True
        return state not in _RUNNING_TI_STATES

    def _build_session_response(
        session: Session, *, dag_id: str, run_id: str, task_id: str, map_index: int = -1
    ) -> HITLReviewResponse | None:
        """Build `HITLReviewResponse` from XCom entries."""
        raw = _read_xcom(
            session,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=XCOM_AGENT_SESSION,
        )
        if raw is None:
            return None
        sess_data = AgentSessionData.model_validate(raw)
        outputs = _read_xcom_by_prefix(
            session,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            prefix=XCOM_AGENT_OUTPUT_PREFIX,
        )
        human_responses = _read_xcom_by_prefix(
            session,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            prefix=XCOM_HUMAN_FEEDBACK_PREFIX,
        )
        completed = _is_task_completed(
            session,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
        )
        return HITLReviewResponse.from_xcom(
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            session=sess_data,
            outputs=outputs,
            human_entries=human_responses,
            task_completed=completed,
        )

    def _get_map_index(q: str = Query("-1", alias="map_index")) -> int:
        """Parse map_index query; use -1 when placeholder unreplaced (e.g. ``{MAP_INDEX}``) or invalid."""
        try:
            return int(q)
        except (ValueError, TypeError):
            return -1

    MapIndexDep = Annotated[int, Depends(_get_map_index)]

    hitl_review_app = FastAPI(
        title="HITL Review",
        description=(
            "REST API and chat UI for human-in-the-loop LLM feedback sessions.  "
            "Sessions are stored in XCom entries on the running task instance."
        ),
    )

    @hitl_review_app.get("/health")
    async def health() -> dict[str, str]:
        """Liveness check."""
        return {"status": "ok"}

    @hitl_review_app.get(
        "/sessions/find",
        response_model=HITLReviewResponse,
        dependencies=[Depends(requires_access_dag(method="GET", access_entity=DagAccessEntity.HITL_DETAIL))],
    )
    async def find_session(
        db: SessionDep,
        dag_id: str,
        task_id: str,
        run_id: str,
        map_index: MapIndexDep,
    ) -> HITLReviewResponse:
        """Find the feedback session for a specific task instance."""
        resp = _build_session_response(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
        )
        if resp is None:
            task_active = not _is_task_completed(
                db,
                dag_id=dag_id,
                run_id=run_id,
                task_id=task_id,
                map_index=map_index,
            )
            raise HTTPException(
                status_code=404,
                detail={"message": "No matching session found.", "task_active": task_active},
            )
        return resp

    @hitl_review_app.post(
        "/sessions/feedback",
        response_model=HITLReviewResponse,
        dependencies=[Depends(requires_access_dag(method="PUT", access_entity=DagAccessEntity.HITL_DETAIL))],
    )
    async def submit_feedback(
        body: HumanFeedbackRequest,
        db: SessionDep,
        dag_id: str,
        task_id: str,
        run_id: str,
        map_index: MapIndexDep,
    ) -> HITLReviewResponse:
        """Request changes — provide human feedback for the LLM."""
        if not (body.feedback and body.feedback.strip()):
            raise HTTPException(
                status_code=400,
                detail="Feedback is required when requesting changes.",
            )
        raw = _read_xcom(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=XCOM_AGENT_SESSION,
        )
        if raw is None:
            raise HTTPException(status_code=404, detail="No matching session found.")
        sess_data = AgentSessionData.model_validate(raw)
        if sess_data.status != SessionStatus.PENDING_REVIEW:
            raise HTTPException(
                status_code=409,
                detail=f"Session is '{sess_data.status.value}', expected 'pending_review'.",
            )

        iteration = sess_data.iteration
        _write_xcom(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=f"{XCOM_HUMAN_FEEDBACK_PREFIX}{iteration}",
            value=body.feedback,
        )

        action = HumanActionData(action="changes_requested", feedback=body.feedback, iteration=iteration)
        _write_xcom(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=XCOM_HUMAN_ACTION,
            value=action.model_dump(mode="json"),
        )

        sess_data.status = SessionStatus.CHANGES_REQUESTED
        _write_xcom(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=XCOM_AGENT_SESSION,
            value=sess_data.model_dump(mode="json"),
        )

        resp = _build_session_response(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
        )
        if resp is None:
            raise HTTPException(status_code=500, detail="Failed to read session after update.")
        return resp

    @hitl_review_app.post(
        "/sessions/approve",
        response_model=HITLReviewResponse,
        dependencies=[Depends(requires_access_dag(method="PUT", access_entity=DagAccessEntity.HITL_DETAIL))],
    )
    async def approve_session(
        db: SessionDep,
        dag_id: str,
        task_id: str,
        run_id: str,
        map_index: MapIndexDep,
    ) -> HITLReviewResponse:
        """Approve the current output."""
        raw = _read_xcom(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=XCOM_AGENT_SESSION,
        )
        if raw is None:
            raise HTTPException(status_code=404, detail="No matching session found.")

        sess_data = AgentSessionData.model_validate(raw)
        if sess_data.status != SessionStatus.PENDING_REVIEW:
            raise HTTPException(
                status_code=409,
                detail=f"Session is '{sess_data.status.value}', expected 'pending_review'.",
            )

        action = HumanActionData(action="approve", iteration=sess_data.iteration)
        _write_xcom(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=XCOM_HUMAN_ACTION,
            value=action.model_dump(mode="json"),
        )

        sess_data.status = SessionStatus.APPROVED
        _write_xcom(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=XCOM_AGENT_SESSION,
            value=sess_data.model_dump(mode="json"),
        )

        resp = _build_session_response(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
        )
        if resp is None:
            raise HTTPException(status_code=500, detail="Failed to read session after update.")
        return resp

    @hitl_review_app.post(
        "/sessions/reject",
        response_model=HITLReviewResponse,
        dependencies=[Depends(requires_access_dag(method="PUT", access_entity=DagAccessEntity.HITL_DETAIL))],
    )
    async def reject_session(
        db: SessionDep,
        dag_id: str,
        task_id: str,
        run_id: str,
        map_index: MapIndexDep,
    ) -> HITLReviewResponse:
        """Reject the output."""
        raw = _read_xcom(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=XCOM_AGENT_SESSION,
        )
        if raw is None:
            raise HTTPException(status_code=404, detail="No matching session found.")
        sess_data = AgentSessionData.model_validate(raw)
        if sess_data.status != SessionStatus.PENDING_REVIEW:
            raise HTTPException(
                status_code=409,
                detail=f"Session is '{sess_data.status.value}', expected 'pending_review'.",
            )

        action = HumanActionData(action="reject", iteration=sess_data.iteration)
        _write_xcom(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=XCOM_HUMAN_ACTION,
            value=action.model_dump(mode="json"),
        )

        sess_data.status = SessionStatus.REJECTED
        _write_xcom(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
            key=XCOM_AGENT_SESSION,
            value=sess_data.model_dump(mode="json"),
        )

        resp = _build_session_response(
            db,
            dag_id=dag_id,
            run_id=run_id,
            task_id=task_id,
            map_index=map_index,
        )
        if resp is None:
            raise HTTPException(status_code=500, detail="Failed to read session after update.")
        return resp

    # Ensure proper MIME types for plugin bundle (FastAPI serves .cjs as text/plain by default)
    mimetypes.add_type("application/javascript", ".cjs")

    _WWW_DIR = Path(__file__).parent / "www"
    _dist_dir = _WWW_DIR / "dist"
    if _dist_dir.is_dir():
        hitl_review_app.mount(
            "/static",
            StaticFiles(directory=str(_dist_dir.absolute()), html=True),
            name="hitl_review_static",
        )


[docs] class HITLReviewPlugin(AirflowPlugin): """Register the HITL Review REST API + chat UI on the Airflow API server."""
[docs] name = "hitl_review"
[docs] fastapi_apps: list[dict[str, Any]] = []
[docs] react_apps: list[dict[str, str]] = []
if AIRFLOW_V_3_1_PLUS: fastapi_apps = [ { "name": "hitl-review", "app": hitl_review_app, "url_prefix": _PLUGIN_PREFIX, } ] react_apps = [ { "name": "HITL Review", "bundle_url": _get_bundle_url(), "destination": "task_instance", "url_route": "hitl-review", } ]

Was this entry helpful?