diff --git a/CHANGELOG.md b/CHANGELOG.md index 06321db3..699379ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- **`AutoApprovalClassifier(fallback=)` 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 diff --git a/docs/core/approval-classifier.md b/docs/core/approval-classifier.md index 7ae1d5c6..7e4acb19 100644 --- a/docs/core/approval-classifier.md +++ b/docs/core/approval-classifier.md @@ -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 diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 7dc719e3..499345ac 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -9,6 +9,12 @@ All notable changes to Promptise Foundry are documented here. --- +## Unreleased + +### Fixed + +- **`AutoApprovalClassifier(fallback=)` 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 diff --git a/llms-full.txt b/llms-full.txt index a2f11cbe..d5296bf3 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -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 diff --git a/src/promptise/approval_classifier.py b/src/promptise/approval_classifier.py index 8ca0d472..9b867b40 100644 --- a/src/promptise/approval_classifier.py +++ b/src/promptise/approval_classifier.py @@ -259,9 +259,11 @@ class AutoApprovalClassifier: ``"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"``. @@ -287,11 +289,22 @@ def __init__( 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 []) diff --git a/tests/test_approval_classifier.py b/tests/test_approval_classifier.py index 98e58010..f7eb7e47 100644 --- a/tests/test_approval_classifier.py +++ b/tests/test_approval_classifier.py @@ -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):