deer-flow/backend/app/gateway/hitl_approval.py
Wenchao An 3d826c6d95
feat(approvals): add Ask, Auto and Full tool permission modes (#8033)
* feat(hitl): add human-in-the-loop approval for high-risk tool calls

* fix(hitl): address approval review findings

* docs(hitl): keep agent guidance under size limit

* fix(hitl): guard approval pause recovery

* docs(hitl): shrink inherited guidance

* fix(hitl): harden approval admission and recovery

* fix(approvals): address review safety and reconciliation gaps

* ci: retry approval checks after runner timeout

---------

Co-authored-by: wangzeren <1004695029@qq.com>
2026-11-11 20:59:05 +08:00

352 lines
17 KiB
Python

"""Server-side validation for HITL tool-approval resume and admission.
All checks live here (called from ``services.start_run``) so every entry point
— HTTP routes, extension host, internal launchers — shares one choke point.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from fastapi import HTTPException
from app.gateway.checkpoint_lineage import hitl_approval_payloads
from deerflow.approvals.protocol import combine_approval_payloads
from deerflow.runtime.checkpoint_history import recent_checkpoint_history
logger = logging.getLogger(__name__)
class ApprovalRequestIds(dict):
"""Identity map plus the validated LangGraph interrupt-ID resume map."""
resume_map: dict | None = None
if TYPE_CHECKING:
from deerflow.runtime.runs.manager import ApprovalAnswerHandle
def _error(status: int, code: str, message: str) -> HTTPException:
return HTTPException(status_code=status, detail={"code": code, "message": message})
def _responses_of(command: Any) -> list[Any] | None:
if not isinstance(command, Mapping):
return None
resume = command.get("resume")
if not isinstance(resume, Mapping):
return None
responses = resume.get("responses")
return responses if isinstance(responses, list) else None
def _all_reject(responses: list[Any] | None) -> bool:
return bool(responses) and all(isinstance(r, Mapping) and r.get("option_id") == "reject" for r in responses)
async def has_any_pending_interrupt(accessor: Any, read_config: dict[str, Any]) -> bool:
"""Whether a recent checkpoint has an unconsumed interrupt write.
Used for generic (non-hitl) resume admission where snapshot.next may be
empty after a head-moving write. The newest resume/interrupt marker wins,
so an answered pause cannot be resurrected by its older interrupt write.
"""
async for checkpoint in recent_checkpoint_history(accessor.checkpointer, read_config):
writes = list(getattr(checkpoint, "pending_writes", ()) or ())
if any(len(write) >= 2 and write[1] == "__resume__" for write in writes):
return False
if any(len(write) >= 2 and write[1] == "__interrupt__" for write in writes):
return True
return False
async def _pending_interrupts_from_checkpoint_history(checkpointer: Any, read_config: dict[str, Any]) -> dict[str, list[Any]]:
"""Read pending interrupts without materializing a graph state snapshot."""
async for checkpoint in recent_checkpoint_history(checkpointer, read_config):
writes = list(getattr(checkpoint, "pending_writes", ()) or ())
if any(len(write) >= 2 and write[1] == "__resume__" for write in writes):
return {}
interrupt_writes = [write for write in writes if len(write) >= 3 and write[1] == "__interrupt__"]
if not interrupt_writes:
continue
grouped: dict[str, list[Any]] = {}
for write in interrupt_writes:
interrupts = write[2] if isinstance(write[2], (list, tuple)) else (write[2],)
grouped.setdefault(str(write[0]), []).extend(interrupts)
return grouped
return {}
async def has_waiting_for_input_run(request: Any, thread_id: str) -> bool:
"""Whether any run in the thread is paused for input (any interrupt kind)."""
from app.gateway.deps import get_run_store
try:
run_store = get_run_store(request)
except (HTTPException, AttributeError) as exc:
if isinstance(exc, AttributeError) or exc.status_code == 503:
return False
raise
runs = await run_store.list_by_thread(thread_id, limit=5)
return any(run.get("status") == "waiting_for_input" for run in runs)
async def mark_approval_answered(request: Any, thread_id: str, *, expected_request_ids: dict[str, str] | None = None) -> ApprovalAnswerHandle | None:
"""Mark the validated paused run as answered and return its recovery handle."""
from app.gateway.deps import get_run_manager, get_run_store
try:
run_manager = get_run_manager(request)
except (HTTPException, AttributeError) as exc:
if isinstance(exc, HTTPException) and exc.status_code != 503:
raise
# Lightweight compositions may provide only a store. Keep their
# persistence on RunManager's retry and in-memory mutation path too.
from deerflow.runtime import RunManager
try:
run_manager = RunManager(store=get_run_store(request))
except (HTTPException, AttributeError) as store_exc:
if isinstance(store_exc, AttributeError) or store_exc.status_code == 503:
return None
raise
return await run_manager.mark_approval_answered(thread_id, expected_request_ids=expected_request_ids)
async def _interrupt_from_checkpoint_history(accessor: Any, read_config: dict[str, Any], payload: dict[str, Any]) -> tuple[str, Any] | None:
"""Recover the original LangGraph task identity after the head has moved."""
if accessor is None:
return None
if getattr(accessor, "checkpointer", None) is None:
return None
async for checkpoint in recent_checkpoint_history(accessor.checkpointer, read_config):
writes = list(getattr(checkpoint, "pending_writes", ()) or ())
if any(len(write) >= 2 and write[1] == "__resume__" for write in writes):
return None
for write in writes:
if len(write) < 3 or write[1] != "__interrupt__":
continue
interrupts = write[2] if isinstance(write[2], (list, tuple)) else (write[2],)
for pending_interrupt in interrupts:
if getattr(pending_interrupt, "value", None) == payload:
return str(write[0]), pending_interrupt
return None
async def find_pending_interrupts(
request: Any,
thread_id: str,
*,
accessor: Any = None,
read_config: dict[str, Any] | None = None,
snapshot: Any = None,
) -> dict[str, list[Any]]:
"""Find pending interrupts from an existing snapshot or raw checkpoints.
The live snapshot catches a pause before the worker has finalized the run;
raw pending writes cover head-moving writes and restricted mutation graphs;
the durable run record remains the final fallback.
"""
from langgraph.types import Interrupt
from app.gateway.checkpoint_lineage import task_interrupts_by_id
from app.gateway.deps import get_checkpointer, get_run_store
if snapshot is None and accessor is not None and read_config is not None:
snapshot = await accessor.aget(read_config)
if snapshot is not None:
grouped = task_interrupts_by_id(snapshot)
if grouped:
return grouped
try:
run_store = get_run_store(request)
except (HTTPException, AttributeError) as exc:
if not isinstance(exc, AttributeError) and exc.status_code != 503:
raise
runs = []
else:
runs = await run_store.list_by_thread(thread_id, limit=10)
# A current loop snapshot is authoritative for ordinary polling. Update
# checkpoints can have moved the head past an unconsumed interrupt, and
# checkpoint-only compositions have no snapshot: retain recovery for both.
if (
snapshot is not None
and (getattr(snapshot, "metadata", None) or {}).get("source") == "loop"
and (getattr(snapshot, "config", None) or {}).get("configurable", {}).get("checkpoint_id")
and not (read_config or {}).get("configurable", {}).get("checkpoint_id")
and not any(run.get("status") == "waiting_for_input" for run in runs)
):
return {}
checkpointer = getattr(accessor, "checkpointer", None)
if checkpointer is None:
try:
checkpointer = get_checkpointer(request)
except (HTTPException, AttributeError) as exc:
if not isinstance(exc, AttributeError) and exc.status_code != 503:
raise
if checkpointer is not None:
grouped = await _pending_interrupts_from_checkpoint_history(
checkpointer,
read_config or {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
)
if grouped:
return grouped
for run in runs:
if run.get("status") != "waiting_for_input":
continue
payload = (run.get("metadata") or {}).get("hitl_approval")
if payload:
recovered = await _interrupt_from_checkpoint_history(accessor, read_config, payload) if read_config is not None else None
if recovered is not None:
task_id, pending_interrupt = recovered
return {task_id: [pending_interrupt]}
task_id = run.get("run_id") or "approval"
return {task_id: [Interrupt(value=payload, id=payload.get("request_id", task_id))]}
return {}
def _approval_request_ids(payload: dict[str, Any]) -> dict[str, str]:
"""Extract and validate the action identity binding for one HITL pause."""
expected_ids = payload.get("pending_tool_call_ids")
if not isinstance(expected_ids, list) or any(not isinstance(call_id, str) for call_id in expected_ids) or len(set(expected_ids)) != len(expected_ids):
raise _error(422, "invalid_approval_response", "pending approval has invalid tool_call_ids")
expected_set = set(expected_ids)
actions = payload.get("actions")
if not isinstance(actions, list):
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
expected_request_ids: dict[str, str] = {}
for action in actions:
if not isinstance(action, Mapping):
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
call_id = action.get("tool_call_id")
request_id = action.get("request_id")
if not isinstance(call_id, str) or call_id not in expected_set or call_id in expected_request_ids or not isinstance(request_id, str) or not request_id:
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
expected_request_ids[call_id] = request_id
if set(expected_request_ids) != expected_set:
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
return expected_request_ids
async def current_approval_request_ids(
request: Any,
thread_id: str,
*,
accessor: Any,
read_config: dict[str, Any],
) -> dict[str, str] | None:
"""Read the current live pause's identity for checkpoint-only recovery."""
pending = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=read_config)
payloads = hitl_approval_payloads(pending)
return _approval_request_ids(combine_approval_payloads(payloads)) if payloads else None
async def validate_resume_request(*, request: Any, thread_id: str, accessor: Any, read_config: dict[str, Any], enabled: bool, body: Any) -> dict[str, str] | None:
"""Validate a command.resume against the current pause, or raise 409/422.
Static breakpoints (next non-empty, no approval payload) pass through
unchanged. All-reject remains the escape hatch for the validated pause when
the feature is disabled or the payload was lost; attachment later verifies
that checkpoint-only recovery still targets that same pause.
"""
snapshot = await accessor.aget(read_config)
responses = _responses_of(getattr(body, "command", None))
all_reject = _all_reject(responses)
pending = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=read_config, snapshot=snapshot)
payloads = hitl_approval_payloads(pending)
has_waiting_run = await has_waiting_for_input_run(request, thread_id)
has_pending_interrupt = await has_any_pending_interrupt(accessor, read_config)
head_checkpoint_id = (getattr(snapshot, "config", None) or {}).get("configurable", {}).get("checkpoint_id")
if not pending and not has_waiting_run and not (getattr(snapshot, "next", None) or ()) and not has_pending_interrupt:
raise _error(409, "no_pending_approval", "No resumable pause exists for this thread")
if not payloads:
return None
if responses is None:
raise _error(422, "invalid_approval_response", "resume must carry a 'responses' list for hitl_approval")
expected_request_ids = ApprovalRequestIds(_approval_request_ids(combine_approval_payloads(payloads)))
action_by_request = {action["request_id"]: action for payload in payloads for action in payload["actions"]}
expected_ids = list(expected_request_ids)
expected_set = set(expected_ids)
if len(responses) != len(expected_ids):
raise _error(422, "invalid_approval_response", f"expected {len(expected_ids)} responses, got {len(responses)}")
seen_ids: set[str] = set()
for response in responses:
if not isinstance(response, Mapping):
raise _error(422, "invalid_approval_response", "each response must be an object")
response_id = response.get("tool_call_id")
if not isinstance(response_id, str) or response_id not in expected_set:
raise _error(422, "invalid_approval_response", "unknown tool_call_id")
if response.get("request_id") != expected_request_ids[response_id]:
raise _error(422, "invalid_approval_response", "stale or mismatched request_id")
if response_id in seen_ids:
raise _error(422, "invalid_approval_response", "duplicate tool_call_id")
seen_ids.add(response_id)
action = action_by_request[response["request_id"]]
supported = ("approve", "reject", "edit", "approve_thread") if all(payload.get("version") == 2 for payload in payloads) else ("approve", "reject")
if response.get("option_id") not in supported:
raise _error(422, "invalid_approval_response", f"unsupported option_id; expected one of {', '.join(repr(option) for option in supported)}")
if response.get("option_id") == "approve_thread" and not action.get("grant_allowed"):
raise _error(422, "invalid_approval_response", "This operation does not permit a thread grant")
if response.get("option_id") == "edit":
from jsonschema import Draft202012Validator
from deerflow.approvals.policy import canonical_json
args, schema = response.get("args"), action.get("args_schema")
try:
limit = min(payload.get("max_payload_bytes", 512 * 1024) for payload in payloads)
if not isinstance(args, dict) or not isinstance(schema, dict) or len(canonical_json(args).encode()) > limit:
raise ValueError("Invalid arguments")
Draft202012Validator(schema).validate(args)
if isinstance(schema.get("properties"), dict) and set(args) - set(schema["properties"]):
raise ValueError("Unknown or injected arguments")
except Exception as exc:
raise _error(422, "invalid_approval_arguments", "Edited arguments do not match the tool schema") from exc
if all(payload.get("version") == 2 for payload in payloads):
expected_request_ids.resume_map = {
item.id: {"responses": [response for response in responses if response["request_id"] in {action["request_id"] for action in item.value["actions"]}]}
for interrupts in pending.values()
for item in interrupts
if isinstance(getattr(item, "value", None), dict) and item.value.get("kind") == "hitl_approval"
}
if all_reject:
return expected_request_ids
if not enabled:
raise _error(409, "hitl_approval_disabled", "hitl_approval is currently disabled; resume with all-reject to clear the pause")
requested_checkpoint_id = getattr(body, "checkpoint_id", None)
body_checkpoint = getattr(body, "checkpoint", None)
if requested_checkpoint_id is None and isinstance(body_checkpoint, Mapping):
requested_checkpoint_id = body_checkpoint.get("checkpoint_id")
if requested_checkpoint_id and head_checkpoint_id and str(requested_checkpoint_id) != str(head_checkpoint_id):
raise _error(422, "checkpoint_not_head", "A resume must target the current head checkpoint, never an older pause checkpoint")
return expected_request_ids
async def reject_new_run_when_approval_pending(
request: Any,
thread_id: str,
*,
accessor: Any = None,
read_config: dict[str, Any] | None = None,
) -> None:
"""Reject a brand-new (non-resume) run while an approval is pending.
Without this guard a fresh input silently discards the pending approval
(measured behavior of LangGraph). Internal launchers catch this specific
error code to implement skip-and-retry semantics.
"""
pending = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=read_config)
if hitl_approval_payloads(pending):
raise _error(409, "approval_pending", "Thread has a pending tool approval; resume or reject it before starting a new run")