Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ COPY pyproject.toml README.md ./
COPY src/ src/

RUN pip install --no-cache-dir --prefix=/install \
"agentfield" \
"agentfield>=0.1.84" \
"hax-sdk>=0.2.4" \
"pydantic>=2.0" \
"httpx>=0.27" \
"python-dotenv>=1.0" \
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ license = "Apache-2.0"
requires-python = ">=3.11"
authors = [{ name = "AgentField", email = "hello@agentfield.dev" }]
dependencies = [
"agentfield",
"agentfield>=0.1.84",
"hax-sdk>=0.2.4",
"pydantic>=2.0",
"httpx>=0.27",
"pyyaml>=6.0",
Expand Down
30 changes: 30 additions & 0 deletions src/pr_af/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,43 @@ class DepthProfile(BaseModel):
}


class HITLConfig(BaseModel):
"""Human-in-the-loop review gate (mirrors SWE-AF's plan-phase approval).

When enabled, PR-AF does not post its review directly. Instead it summarizes
the findings, sends a hax form request to a workspace member, and pauses
until they approve a subset, request a re-review with instructions, or
reject. Auto-enables when ``HAX_API_KEY`` is set — same trigger SWE-AF uses
(``build_hax_client_from_env`` returns ``None`` when it is unset, which the
orchestrator treats as "HITL off, post directly").
"""

# Mirrors the on/off switch in build_hax_client_from_env: HITL is active
# only when HAX_API_KEY is present. Kept here for observability/overrides.
enabled: bool = Field(
default_factory=lambda: bool(os.getenv("HAX_API_KEY", "").strip())
)
# Optional routing: which hax workspace user receives the request.
approval_user_id: str | None = Field(
default_factory=lambda: os.getenv("AGENTFIELD_APPROVAL_USER_ID") or None
)
# How long the pause stays open before it expires (treated as a reject).
# Plain config default — matches SWE-AF's BuildConfig.approval_expires_in_hours
# (not env-driven, to avoid introducing PR-AF-specific env var names).
approval_expires_in_hours: int = 72
# How many "re-review with instructions" rounds before giving up (no post).
# Matches SWE-AF's BuildConfig.max_plan_revision_iterations.
max_review_revisions: int = 2


class ReviewConfig(BaseModel):
"""Top-level configuration combining all sub-configs."""

budget: BudgetConfig = Field(default_factory=BudgetConfig)
models: ModelConfig = Field(default_factory=ModelConfig)
scoring: ScoringConfig = Field(default_factory=ScoringConfig)
comments: CommentConfig = Field(default_factory=CommentConfig)
hitl: HITLConfig = Field(default_factory=HITLConfig)

# File ignore patterns (glob)
ignore_paths: list[str] = Field(
Expand Down
38 changes: 38 additions & 0 deletions src/pr_af/hitl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Human-in-the-loop review gate for PR-AF.

Mirrors SWE-AF's plan-phase approval: when ``HAX_API_KEY`` is set, PR-AF pauses
before posting a review and routes the findings to a hax workspace member for
per-finding approval, re-review, or rejection.
"""

from __future__ import annotations

from .client import (
approval_webhook_url,
build_hax_client_from_env,
create_hax_form_request_with_timeout,
extract_values_from_raw,
)
from .review_gate import (
ACTION_POST,
ACTION_REJECT,
ACTION_RERUN,
ReviewDecision,
build_review_form,
parse_review_decision,
request_review_approval,
)

__all__ = [
"ACTION_POST",
"ACTION_REJECT",
"ACTION_RERUN",
"ReviewDecision",
"approval_webhook_url",
"build_hax_client_from_env",
"build_review_form",
"create_hax_form_request_with_timeout",
"extract_values_from_raw",
"parse_review_decision",
"request_review_approval",
]
146 changes: 146 additions & 0 deletions src/pr_af/hitl/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""hax-sdk plumbing for the PR-AF human-in-the-loop review gate.

Ported from SWE-AF's ``swe_af.hitl.ask_user`` — the hax client builder, the
control-plane webhook URL resolver, the watchdog-safe ``create_request``
wrapper, and the helper that digs form values out of an ``ApprovalResult``.

``hax`` is imported lazily so this module (and the orchestrator that imports it)
stays importable in environments without the SDK installed.
"""

from __future__ import annotations

import asyncio
import os
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from hax import HaxClient


# Same default as SWE-AF: hax service on :3000, REST under /api/v1.
_DEFAULT_HAX_BASE = "http://localhost:3000"

# SWE-AF's lesson (test_pause_watchdog_e2e.py): the synchronous
# hax_client.create_request can wedge for tens of minutes and burn the
# reasoner's active-time budget, so every call is wrapped in a hard timeout.
HAX_CREATE_REQUEST_TIMEOUT_SECONDS = 120.0


def build_hax_client_from_env() -> HaxClient | None:
"""Construct a ``HaxClient`` from ``HAX_API_KEY`` / ``HAX_SDK_URL``.

Returns ``None`` when ``HAX_API_KEY`` is unset or empty — callers treat that
as "HITL disabled" and post the review directly. This is the on/off switch.
"""
api_key = os.environ.get("HAX_API_KEY", "").strip()
if not api_key:
return None
from hax import HaxClient

base = os.environ.get("HAX_SDK_URL", _DEFAULT_HAX_BASE).rstrip("/")
return HaxClient(api_key=api_key, base_url=f"{base}/api/v1")


def approval_webhook_url(app: Any) -> str | None:
"""Resolve the control-plane webhook URL for ``app.pause`` callbacks.

Mirrors the URL SWE-AF's plan-approval gate uses
(``{cp_base_url}/api/v1/webhooks/approval-response``). Returns ``None`` when
no control-plane URL can be resolved.
"""
cp_base = (
getattr(app, "agentfield_server", None)
or os.environ.get("AGENTFIELD_SERVER")
or ""
).rstrip("/")
if not cp_base:
return None
return f"{cp_base}/api/v1/webhooks/approval-response"


async def create_hax_form_request_with_timeout(
*,
app: Any,
hax_client: HaxClient,
form: Any,
title: str,
description: str | None,
expires_in_seconds: int,
user_id: str | None,
webhook_url: str | None,
metadata: dict[str, Any] | None,
timeout_seconds: float = HAX_CREATE_REQUEST_TIMEOUT_SECONDS,
) -> Any:
"""Submit a hax form-builder request with a hard timeout.

Runs the synchronous ``hax_client.create_request`` in a worker thread under
``asyncio.wait_for`` so a wedged hax-sdk fails fast (``RuntimeError``)
instead of silently burning the reasoner's active-time budget. Returns the
``CreatedRequest``; the caller passes ``.id`` / ``.url`` to ``app.pause``.
"""
app.note(
f"hitl: submitting hax form request ({title!r})",
tags=["hitl", "hax", "create_request"],
)

kwargs: dict[str, Any] = {
"type": "form-builder",
"payload": form.to_payload(),
"title": title,
"expires_in_seconds": expires_in_seconds,
}
if description is not None:
kwargs["description"] = description
if user_id is not None:
kwargs["user_id"] = user_id
if webhook_url is not None:
kwargs["webhook_url"] = webhook_url
if metadata is not None:
kwargs["metadata"] = metadata

try:
created = await asyncio.wait_for(
asyncio.to_thread(hax_client.create_request, **kwargs),
timeout=timeout_seconds,
)
except TimeoutError as exc:
app.note(
f"hitl: hax create_request timed out after {timeout_seconds}s",
tags=["hitl", "hax", "timeout"],
)
raise RuntimeError(
f"hax-sdk create_request (form-builder) timed out after "
f"{timeout_seconds}s; hax-sdk is likely wedged."
) from exc
except Exception as exc:
app.note(
f"hitl: hax create_request raised {type(exc).__name__}: {exc}",
tags=["hitl", "hax", "error"],
)
raise

app.note(
f"hitl: hax form request created (request_id={created.id})",
tags=["hitl", "hax", "submitted"],
)
return created


def extract_values_from_raw(raw: Any) -> dict[str, Any]:
"""Find the submitted form values inside an ``ApprovalResult.raw_response``.

hax delivers values at ``raw['values']`` or ``raw['response']['values']``
depending on the callback shape; check both.
"""
if not isinstance(raw, dict):
return {}
direct = raw.get("values")
if isinstance(direct, dict):
return dict(direct)
response_obj = raw.get("response")
if isinstance(response_obj, dict):
inner = response_obj.get("values")
if isinstance(inner, dict):
return dict(inner)
return {}
Loading
Loading