Skip to content
Draft
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixed

- **`AutoApprovalClassifier(fallback=<function>)` failed at the first escalation** -- a bare function was accepted when the classifier was built, then the first request that reached the fallback raised `AttributeError: 'function' object has no attribute 'request_approval'`. A function (plain or async) is now wrapped as `ApprovalPolicy(handler=...)` wraps one, and anything else is refused with a `TypeError` when the classifier is built.

## 1.10.1 — 2026-10-24

### Security
Expand Down
2 changes: 2 additions & 0 deletions docs/core/approval-classifier.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ policy = ApprovalPolicy(
)
```

`fallback` takes any approval handler, or a function (plain or `async`) that receives the request and returns an `ApprovalDecision` or a bool, the same as `ApprovalPolicy(handler=...)`.

---

## The 6-layer hierarchy
Expand Down
6 changes: 6 additions & 0 deletions docs/resources/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ All notable changes to Promptise Foundry are documented here.

---

## Unreleased

### Fixed

- **`AutoApprovalClassifier(fallback=<function>)` failed at the first escalation** -- a bare function was accepted when the classifier was built, then the first request that reached the fallback raised `AttributeError: 'function' object has no attribute 'request_approval'`. A function (plain or async) is now wrapped as `ApprovalPolicy(handler=...)` wraps one, and anything else is refused with a `TypeError` when the classifier is built.

## v1.10.1 — 2026-10-24

### Security
Expand Down
2 changes: 2 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9836,6 +9836,8 @@ policy = ApprovalPolicy(
)
```

`fallback` takes any approval handler, or a function (plain or `async`) that receives the request and returns an `ApprovalDecision` or a bool, the same as `ApprovalPolicy(handler=...)`.

---

## The 6-layer hierarchy
Expand Down
21 changes: 17 additions & 4 deletions src/promptise/approval_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,11 @@
``"escalate"``. ``escalate`` defers to the fallback
handler. It receives the real (unredacted) arguments.
fallback: The :class:`ApprovalHandler` to delegate to as a
last resort. Required. It receives the reviewer's copy of
the request (``arguments`` redacted per the policy, no
``raw_arguments``).
last resort, or a function (plain or ``async``) taking the
request and returning an :class:`ApprovalDecision` or a bool,
wrapped as ``ApprovalPolicy(handler=...)`` does. Required. It receives
the reviewer's copy of the request (``arguments`` redacted per
the policy, no ``raw_arguments``).
reviewer_id: Identifier recorded on auto-decisions. Defaults
to ``"auto-classifier"``.

Expand All @@ -287,11 +289,22 @@
destructive_verbs: tuple[str, ...] | list[str] | None = None,
use_tool_annotations: bool = True,
llm_classifier: LLMClassifierFn | None = None,
fallback: ApprovalHandler,
fallback: ApprovalHandler | Callable[..., Any],
reviewer_id: str = "auto-classifier",
) -> None:
if fallback is None:
raise ValueError("AutoApprovalClassifier requires a fallback handler")
if not isinstance(fallback, ApprovalHandler):
if not callable(fallback):
raise TypeError(
"fallback must be an ApprovalHandler or a function, "
f"got {type(fallback).__name__}"
)
# A bare function, as ApprovalPolicy(handler=...) accepts; it used
# to pass here and fail at the first escalation.
from .approval import CallbackApprovalHandler

fallback = CallbackApprovalHandler(fallback)
self._allow_rules = list(allow_rules or [])
self._deny_rules = list(deny_rules or [])
self._ask_rules = list(ask_rules or [])
Expand Down
29 changes: 29 additions & 0 deletions tests/test_approval_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ def _make_request(tool: str, **kwargs) -> ApprovalRequest:
# ---------------------------------------------------------------------------


class TestFunctionFallback:
"""A bare function as fallback used to be accepted, then fail at the first escalation."""

@pytest.mark.asyncio
async def test_async_function_is_called_on_escalation(self):
seen: list[str] = []

async def ask_human(request: ApprovalRequest) -> ApprovalDecision:
seen.append(request.tool_name)
return ApprovalDecision(approved=False, reason="not today")

classifier = AutoApprovalClassifier(read_only_auto_allow=False, fallback=ask_human)
decision = await classifier.request_approval(_make_request("delete_user"))
assert seen == ["delete_user"]
assert decision.approved is False and decision.reason == "not today"

@pytest.mark.asyncio
async def test_plain_function_returning_bool(self):
classifier = AutoApprovalClassifier(
read_only_auto_allow=False, fallback=lambda request: True
)
decision = await classifier.request_approval(_make_request("delete_user"))
assert decision.approved is True

def test_non_callable_fallback_is_rejected_up_front(self):
with pytest.raises(TypeError, match="fallback must be an ApprovalHandler or a function"):
AutoApprovalClassifier(fallback="https://approvals.example.com")


class TestApprovalRule:
@pytest.mark.asyncio
async def test_glob_tool_match(self):
Expand Down
Loading