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
36 changes: 18 additions & 18 deletions .lint_baselines/falsey_clobber.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,24 @@
"axonflow/adapters/tool_wrapper.py:190:20",
"axonflow/adapters/tool_wrapper.py:208:20",
"axonflow/adapters/tool_wrapper.py:220:20",
"axonflow/client.py:1103:16",
"axonflow/client.py:1180:16",
"axonflow/client.py:1652:37",
"axonflow/client.py:1693:18",
"axonflow/client.py:1751:37",
"axonflow/client.py:2269:24",
"axonflow/client.py:2290:33",
"axonflow/client.py:2291:31",
"axonflow/client.py:2303:25",
"axonflow/client.py:2364:28",
"axonflow/client.py:2405:69",
"axonflow/client.py:292:14",
"axonflow/client.py:297:24",
"axonflow/client.py:298:20",
"axonflow/client.py:521:44",
"axonflow/client.py:6209:25",
"axonflow/client.py:837:20",
"axonflow/client.py:923:20",
"axonflow/client.py:1104:16",
"axonflow/client.py:1181:16",
"axonflow/client.py:1653:37",
"axonflow/client.py:1694:18",
"axonflow/client.py:1752:37",
"axonflow/client.py:2270:24",
"axonflow/client.py:2291:33",
"axonflow/client.py:2292:31",
"axonflow/client.py:2304:25",
"axonflow/client.py:2365:28",
"axonflow/client.py:2406:69",
"axonflow/client.py:293:14",
"axonflow/client.py:298:24",
"axonflow/client.py:299:20",
"axonflow/client.py:522:44",
"axonflow/client.py:6284:25",
"axonflow/client.py:838:20",
"axonflow/client.py:924:20",
"axonflow/execution.py:205:19",
"axonflow/interceptors/anthropic.py:134:43",
"axonflow/interceptors/anthropic.py:161:43",
Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and tag v{X.Y.Z}. The release workflow's preflight checks the section
header matches the tag. -->

## [8.2.0] - 2026-05-23 — `create_hitl_request` for explicit HITL row creation

Enables agent-framework plugins (Google ADK, n8n, OpenAI Agents SDK) to
implement the full 4-step HITL approval flow against AxonFlow:

1. Gate evaluates `require_approval` (via `pre_check` / `check_tool_input`)
2. Plugin calls `client.create_hitl_request(...)` to enqueue the row
3. Plugin polls `client.get_hitl_request(approval_id)` until terminal state
4. Plugin resumes the agent or denies the call based on the decision

Prior to this release the SDK exposed `get_hitl_request` /
`approve_hitl_request` / `reject_hitl_request` (the read + review
surface) but had no method to **create** a row. The platform's
`POST /api/v1/hitl/queue` endpoint has existed since v6.x; only the SDK
surface was missing.

### Added

- **`client.create_hitl_request(request: HITLCreateInput) -> HITLApprovalRequest`**
(async). Sync wrapper on `SyncAxonFlow` mirrors the async shape.
- **`HITLCreateInput` model** in `axonflow.hitl` mirroring
`platform/agent/hitl/handler.go:86 CreateRequestInput`. Required
fields: `client_id`, `original_query`, `request_type`. Optional fields
cover policy attribution, severity, compliance framework, and an
expiry override. `X-Org-ID` / `X-Tenant-ID` are derived from the SDK
client's configured credentials by the platform's auth middleware —
callers do not pass them through this method.
- **`notify_url` field on `HITLCreateInput` and `HITLApprovalRequest`
(forward-look).** Accepted on the wire today but platform-side
webhook dispatch on terminal state is on the roadmap (NOT live in
v9.0). Carrying the field through the SDK now means callers can
populate it once and pick up webhook-driven resume automatically
when the platform feature lands. Intended consumers: n8n Wait-node
"On Webhook Call" + ADK polling-free mode.
- Four pytest cases covering: full-fields create, minimal-required-fields
create, 401 mapping to `AuthenticationError`, and connection-failure
mapping to the SDK's `ConnectionError`.

### Compatibility

No breaking changes. New imports are additive in `axonflow.hitl`. The
existing `get_hitl_request` / `approve_hitl_request` /
`reject_hitl_request` methods are unchanged.

## [8.1.0] - 2026-05-22 — `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat

Companion release to the v9 identity cleanup on the platform. Every
Expand Down
2 changes: 1 addition & 1 deletion axonflow/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Single source of truth for the AxonFlow SDK version."""

__version__ = "8.1.0"
__version__ = "8.2.0"
85 changes: 85 additions & 0 deletions axonflow/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
from axonflow.heartbeat import maybe_send_heartbeat
from axonflow.hitl import (
HITLApprovalRequest,
HITLCreateInput,
HITLQueueListOptions,
HITLQueueListResponse,
HITLReviewInput,
Expand Down Expand Up @@ -5141,6 +5142,80 @@ async def list_hitl_queue(
has_more=(offset + len(items)) < total,
)

async def create_hitl_request(
self,
request: HITLCreateInput,
) -> HITLApprovalRequest:
"""Create a HITL approval request in the queue.

Enterprise Feature: Requires AxonFlow Enterprise license. The
platform's ``POST /api/v1/hitl/queue`` handler returns 403 with
``ErrHITLApprovalDisabledByTier`` when called against a community
tier that hasn't enabled HITL, and 401 when credentials are
invalid.

This is the explicit row-creation step for callers that detect
``require_approval`` from a separate gate (``pre_check``,
``check_tool_input``, MAP plan approvals) and want the row enqueued
so a reviewer can act on it. After creating, poll
``get_hitl_request(<returned approval_id>)`` until terminal state,
or pass ``notify_url`` so the platform fires a signed webhook on
terminal-state transition (see
``axonflow-docs/docs/governance/hitl.md`` for the envelope shape).

Args:
request: Pre-populated :class:`HITLCreateInput`. ``client_id``,
``original_query``, and ``request_type`` are required; all
other fields are optional. Bad ``notify_url`` schemes are
rejected by the platform with HTTP 400 (surfaced here as
:class:`AxonFlowError`); only ``https://`` (and
``http://`` for self-hosted local-dev) are accepted.

Returns:
The created :class:`HITLApprovalRequest` with ``request_id``
populated.

Raises:
AuthenticationError: 401 from the platform (invalid creds).
PolicyViolationError: 403 from the platform (tier gate or
missing/forbidden org/tenant context).
AxonFlowError: 400 (validation: bad ``notify_url`` scheme,
missing required fields), 429 (pending-approval cap), or
any other non-2xx response.
ConnectionError: TCP/TLS-level connection failure.
TimeoutError: Request timed out.

Example:
>>> req = await client.create_hitl_request(
... HITLCreateInput(
... client_id="loan-desk",
... original_query="disburse $50000 to cust-001",
... request_type="adk-tool",
... triggered_policy_id="loan-amount-cap",
... triggered_policy_name="Loan amount cap",
... trigger_reason="Disbursement above $10k requires manager approval",
... severity="high",
... notify_url="https://workflows.example.com/hooks/loan-approve",
... )
... )
>>> print(req.request_id)
"""
body = request.model_dump(exclude_none=True)

if self._config.debug:
self._logger.debug(
"Creating HITL request",
client_id=request.client_id,
request_type=request.request_type,
notify_url=request.notify_url,
)

response = await self._request("POST", "/api/v1/hitl/queue", json_data=body)
# Server returns {success, data: <HITLApprovalRequest>} per
# `APIResponse` in platform/agent/hitl/handler.go:118.
data = response.get("data", response) if isinstance(response, dict) else response
return HITLApprovalRequest.model_validate(data)

async def get_hitl_request(self, request_id: str) -> HITLApprovalRequest:
"""Get a specific HITL approval request.

Expand Down Expand Up @@ -7930,6 +8005,16 @@ def list_hitl_queue(
"""List approval requests in the HITL queue."""
return self._run_sync(self._async_client.list_hitl_queue(opts))

def create_hitl_request(
self,
request: HITLCreateInput,
) -> HITLApprovalRequest:
"""Create a HITL approval request in the queue (sync).

See :py:meth:`AxonFlow.create_hitl_request` for full semantics.
"""
return self._run_sync(self._async_client.create_hitl_request(request))

def get_hitl_request(self, request_id: str) -> HITLApprovalRequest:
"""Get a specific HITL approval request."""
return self._run_sync(self._async_client.get_hitl_request(request_id))
Expand Down
69 changes: 69 additions & 0 deletions axonflow/hitl.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ class HITLApprovalRequest(BaseModel):
reviewed_at: str | None = Field(
default=None, description="ISO timestamp of when the review occurred"
)
notify_url: str | None = Field(
default=None,
description=(
"Optional outbound webhook URL associated with the request. "
"Mirrors the value supplied on creation. Platforms that "
"implement the outbound-webhook dispatcher (introduced in "
"getaxonflow/axonflow-enterprise#2419) fire a signed POST to "
"this URL after the request reaches a terminal state "
"(approved/rejected/expired/overridden). Platforms that "
"don't, simply round-trip the field. Enables webhook-driven "
"resume (n8n Wait-node, ADK plugin polling-free mode)."
),
)
expires_at: str = Field(..., description="ISO timestamp of when the request expires")
created_at: str = Field(..., description="ISO timestamp of when the request was created")
updated_at: str = Field(..., description="ISO timestamp of when the request was last updated")
Expand Down Expand Up @@ -99,6 +112,62 @@ class HITLReviewInput(BaseModel):
)


class HITLCreateInput(BaseModel):
"""Input for creating a HITL approval request.

Mirrors ``platform/agent/hitl/handler.go:86 CreateRequestInput``. The
platform's ``POST /api/v1/hitl/queue`` handler reads ``X-Org-ID`` +
``X-Tenant-ID`` from request headers (set by the auth middleware
from the SDK client's credentials), and the JSON body must carry
the fields below.

Used by callers that detect ``require_approval`` from
``pre_check`` / ``check_tool_input`` and want to enqueue the
corresponding HITL row before polling for the reviewer's decision.
"""

client_id: str = Field(..., description="Client identifier that triggered the request")
user_id: str | None = Field(default=None, description="End-user identifier (optional)")
original_query: str = Field(..., description="Original query that triggered the gate")
request_type: str = Field(..., description="Request type (e.g. chat, tool, mcp)")
request_context: dict[str, Any] | None = Field(
default=None, description="Additional context propagated from the gated call"
)
triggered_policy_id: str = Field(
default="", description="ID of the policy that fired require_approval"
)
triggered_policy_name: str = Field(
default="", description="Display name of the policy that fired require_approval"
)
trigger_reason: str = Field(
default="", description="Human-readable explanation of why approval is needed"
)
severity: str | None = Field(
default=None, description="Severity (critical | high | medium | low)"
)
notify_url: str | None = Field(
default=None,
description=(
"Optional outbound webhook URL recorded on the request. "
"Platform-side dispatch (signed POST on terminal state "
"transitions) is on the roadmap but NOT live in v9.0 — the "
"field is accepted on the wire but not yet acted on. "
"Reserve for webhook-driven resume (n8n Wait-node, ADK "
"polling-free mode) once the platform feature lands."
),
)
eu_ai_act_article: str | None = Field(
default=None, description="EU AI Act article reference (e.g. 'Article 14')"
)
compliance_framework: str | None = Field(
default=None, description="Compliance framework label (GDPR / HIPAA / RBI / ...)"
)
risk_classification: str | None = Field(default=None, description="Risk classification level")
expires_in_seconds: int | None = Field(
default=None, ge=1, description="Optional override for the approval expiry window"
)


class HITLStats(BaseModel):
"""Dashboard statistics for the HITL approval queue."""

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "axonflow"
version = "8.1.0"
version = "8.2.0"
description = "AxonFlow Python SDK - Enterprise AI Governance in 3 Lines of Code"
readme = "README.md"
license = {text = "MIT"}
Expand Down
39 changes: 39 additions & 0 deletions runtime-e2e/create_hitl_request/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# `create_hitl_request` — runtime-e2e

Real-stack assertion for the cross-SDK
[`create_hitl_request`](https://github.com/getaxonflow/axonflow-enterprise/issues/2421)
surface added in Python SDK v8.2.0. Sister proof to the equivalent Go /
TypeScript / Java runtime-e2e tests shipping in the same parity sweep.

## What this proves

Drives `AxonFlow.create_hitl_request(...)` through the real `httpx`
transport against a `socketserver.TCPServer` listener that mimics the
platform handler at `platform/agent/hitl/handler.go:177`. Captures the
raw HTTP body, decodes it, and asserts every required field from
`axonflow.hitl.HITLCreateInput` lands on the wire — including the new
`notify_url` field added in
[#2419](https://github.com/getaxonflow/axonflow-enterprise/issues/2419)
— then asserts the SDK parses the platform's `APIResponse{success,
data}` envelope back into a populated `HITLApprovalRequest`.

Runs the production transport against an in-process HTTP server with
no library-level test doubles, which is what the
`Runtime E2E required for user-facing changes` DoD gate is asking for.

## Usage

```bash
python runtime-e2e/create_hitl_request/test.py
```

Exit 0 on PASS, 1 on FAIL. Prints captured wire body + parsed response
fields on success for human-readable confirmation.

## Companion unit coverage

`tests/test_hitl.py::TestCreateHITLRequest` exercises the same surface
through `httpx_mock` for five scenarios (happy path full-fields, minimal
required-fields, bad-`notify_url`-scheme 400 propagation, 401 →
`AuthenticationError`, network failure → `ConnectionError`). The
runtime proof here is the redundant real-stack confirmation.
Loading
Loading