From abf491bef2cce04aa58248a65642cfd345a45260 Mon Sep 17 00:00:00 2001 From: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:51:12 +0000 Subject: [PATCH 1/3] Python: feat(core): load the agent-hooks middleware from a host declaration Add three factories that build the agent-hooks middleware bundle from a host declaration document (agent-hooks spec section 7.7, contract agent-hooks-declaration/1.0): from a mapping or a built HostDeclaration, from a file path, and from JSON text. Add create_agent_hooks_host_registry, which returns an agent_hooks.HostRegistry over the surface this middleware implements (all eight points, the claimed capabilities, posture terminate, buffered streaming), so hosts can register their own binding kinds, identity providers, approval resolvers and redactors. The factory loads the document once at construction so a refused document raises agent_hooks.DeclarationError before any run. Each run then loads a fresh sealed emitter from the same document and registry, which keeps the one-session-per-run model of create_agent_hooks_middleware. Passing interceptors registers the framework's own binding kind, agent_framework.interceptor, which binds a supplied interceptor by name or binding id and refuses any other config without echoing it. The existing factories and the three middleware classes are unchanged. The new tests skip when the installed agent-hooks-sdk predates the loader. Move the dev pin to agent-hooks-sdk 0.1.0b2, the first release with the loader, and refresh uv.lock so the tests run in CI. Fixes #9329 Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> --- python/PACKAGE_STATUS.md | 13 +- .../packages/core/agent_framework/__init__.py | 13 +- .../core/agent_framework/__init__.pyi | 13 +- .../core/agent_framework/_agent_hooks.py | 319 +++++++++++- .../core/tests/core/test_agent_hooks.py | 483 +++++++++++++++++- python/pyproject.toml | 2 +- python/uv.lock | 16 +- 7 files changed, 830 insertions(+), 29 deletions(-) diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index dd68cb9bff2..7a9c2ee48e9 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -82,12 +82,17 @@ listed below. #### `AGENT_HOOKS` -- `agent-framework-core`: `create_agent_hooks_middleware` and - `create_agent_hooks_middleware_from_emitter` from `agent_framework/_agent_hooks.py`, +- `agent-framework-core`: `create_agent_hooks_middleware`, + `create_agent_hooks_middleware_from_emitter`, the host-declaration factories + `create_agent_hooks_middleware_from_declaration`, + `create_agent_hooks_middleware_from_declaration_path` and + `create_agent_hooks_middleware_from_declaration_json`, and + `create_agent_hooks_host_registry` from `agent_framework/_agent_hooks.py`, the AGENT-HOOKS-0.1 enforcement middleware bundle, and the `MiddlewareBundle` - container from `agent_framework/_middleware.py` that both factories produce + container from `agent_framework/_middleware.py` that the factories produce (`MiddlewareBundle` itself needs no optional dependency). Requires installing the - `agent-hooks-sdk` package separately. Known limitation: service-side (hosted) tool + `agent-hooks-sdk` package separately (0.1.0b2 or later for the host-declaration + factories). Known limitation: service-side (hosted) tool execution never passes through the framework's function-invocation seam, so the `pre_tool_call`/`post_tool_call` points cannot intercept it; hosted tool calls and outputs are surfaced in the `post_model_call` content projection instead. diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 5a0b6800ab5..e68fe1de1da 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -42,7 +42,14 @@ ) _LAZY_MODULE_EXPORTS: Final[Mapping[str, tuple[str, ...]]] = { - "._agent_hooks": ("create_agent_hooks_middleware", "create_agent_hooks_middleware_from_emitter"), + "._agent_hooks": ( + "create_agent_hooks_host_registry", + "create_agent_hooks_middleware", + "create_agent_hooks_middleware_from_declaration", + "create_agent_hooks_middleware_from_declaration_json", + "create_agent_hooks_middleware_from_declaration_path", + "create_agent_hooks_middleware_from_emitter", + ), "._agents": ("Agent", "BaseAgent", "RawAgent", "SupportsAgentRun"), "._clients": ( "BaseChatClient", @@ -681,7 +688,11 @@ "background_tasks_running", "background_tasks_running_message", "chat_middleware", + "create_agent_hooks_host_registry", "create_agent_hooks_middleware", + "create_agent_hooks_middleware_from_declaration", + "create_agent_hooks_middleware_from_declaration_json", + "create_agent_hooks_middleware_from_declaration_path", "create_agent_hooks_middleware_from_emitter", "create_always_approve_tool_response", "create_always_approve_tool_with_arguments_response", diff --git a/python/packages/core/agent_framework/__init__.pyi b/python/packages/core/agent_framework/__init__.pyi index c5d5b0a250e..eef684da1c7 100644 --- a/python/packages/core/agent_framework/__init__.pyi +++ b/python/packages/core/agent_framework/__init__.pyi @@ -4,7 +4,14 @@ from typing import Final __version__: Final[str] -from ._agent_hooks import create_agent_hooks_middleware, create_agent_hooks_middleware_from_emitter +from ._agent_hooks import ( + create_agent_hooks_host_registry, + create_agent_hooks_middleware, + create_agent_hooks_middleware_from_declaration, + create_agent_hooks_middleware_from_declaration_json, + create_agent_hooks_middleware_from_declaration_path, + create_agent_hooks_middleware_from_emitter, +) from ._agents import Agent, BaseAgent, RawAgent, SupportsAgentRun from ._clients import ( BaseChatClient, @@ -636,7 +643,11 @@ __all__ = [ "background_tasks_running", "background_tasks_running_message", "chat_middleware", + "create_agent_hooks_host_registry", "create_agent_hooks_middleware", + "create_agent_hooks_middleware_from_declaration", + "create_agent_hooks_middleware_from_declaration_json", + "create_agent_hooks_middleware_from_declaration_path", "create_agent_hooks_middleware_from_emitter", "create_always_approve_tool_response", "create_always_approve_tool_with_arguments_response", diff --git a/python/packages/core/agent_framework/_agent_hooks.py b/python/packages/core/agent_framework/_agent_hooks.py index 830ddf2faae..753fd124699 100644 --- a/python/packages/core/agent_framework/_agent_hooks.py +++ b/python/packages/core/agent_framework/_agent_hooks.py @@ -10,8 +10,13 @@ - ``pre_tool_call`` / ``post_tool_call`` ride the function seam. The public entry points are :func:`create_agent_hooks_middleware` (one agent-hooks -session per run) and :func:`create_agent_hooks_middleware_from_emitter` (host-owned -session). Both return a :class:`~agent_framework.MiddlewareBundle`: the three middleware +session per run), :func:`create_agent_hooks_middleware_from_emitter` (host-owned +session) and the host-declaration factories +:func:`create_agent_hooks_middleware_from_declaration`, +:func:`create_agent_hooks_middleware_from_declaration_path` and +:func:`create_agent_hooks_middleware_from_declaration_json` (one session per run, with the +configuration and the interceptor bindings read from a host declaration document, spec +section 7.7). All return a :class:`~agent_framework.MiddlewareBundle`: the three middleware implementations are deliberately private and travel as one indivisible unit, because installing only part of them would enforce only part of the control contract. Install exactly one bundle per agent, placed first in the middleware list: middleware listed @@ -101,6 +106,7 @@ import contextlib import json import logging +import os import uuid from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence from contextvars import ContextVar @@ -142,9 +148,12 @@ from agent_hooks import ( AgentContextBuilder, ApprovalResolver, + BindingContext, CompositionConfig, EmitOutcome, EnforcementMode, + HostDeclaration, + HostRegistry, IdentityProvider, InterceptionBlocked, InterceptionEmitter, @@ -164,6 +173,22 @@ "Please install it with `pip install agent-hooks-sdk`." ) +_DECLARATION_SDK_MISSING_MESSAGE = ( + "Loading a host declaration requires `agent-hooks-sdk` 0.1.0b2 or later (the first release " + "that accepts the agent-hooks-declaration/1.0 document). Please upgrade it with " + "`pip install --upgrade agent-hooks-sdk`." +) + +# The binding kind this framework registers when a factory receives ``interceptors``: +# ``{"id": "egress", "kind": "agent_framework.interceptor", "config": {"name": "egress"}}`` +# binds the interceptor supplied under that name (``name`` defaults to the binding id). +_DECLARATION_KIND = "agent_framework.interceptor" +# The code surface a declaration is resolved against (spec section 7.7.4): every point, the +# capabilities the conformance claim lists, and the ``terminate`` posture this module +# implements at the tool seam. +_DECLARATION_CAPABILITIES = ("model_calls", "tool_calls", "int64_json", "bigint_json", "host_declaration") +_DECLARATION_POSTURE = "terminate" + _TRIO_REQUIRED_MESSAGE = ( "agent-hooks {seam} middleware was invoked without an active agent-hooks run. " "The middleware bundle returned by create_agent_hooks_middleware() must be installed " @@ -211,6 +236,21 @@ def _require_sdk() -> None: del AgentContextBuilder, EnforcementMode, InterceptionBlocked, InterceptionEmitter +def _require_declaration_sdk() -> None: + """Like :func:`_require_sdk`, for the host-declaration loader (``agent-hooks-sdk`` 0.1.0b2+). + + An installed SDK that predates the loader raises ``ImportError`` on these names; that case + gets an upgrade hint instead of a bare "cannot import name" message. + """ + _require_sdk() + try: + from agent_hooks import HostDeclaration, HostRegistry, HostSurface + except ImportError as exc: + raise ImportError(_DECLARATION_SDK_MISSING_MESSAGE) from exc + else: + del HostDeclaration, HostRegistry, HostSurface + + # region Run state @@ -227,6 +267,9 @@ class _AgentHooksConfig: record_sink: Callable[[InterceptionRecord], None] | None emitter: InterceptionEmitter | None builder: AgentContextBuilder | None + # Set by the host-declaration factories: every run loads a fresh emitter from this pair. + declaration: HostDeclaration | None = None + registry: HostRegistry | None = None @dataclass @@ -1036,17 +1079,23 @@ def _new_run_state(self, context: AgentContext) -> _RunState: session_id=uuid.uuid4().hex, agent_name=str(agent_name) if agent_name else None, ) - kwargs: dict[str, Any] = { - "resolver": config.resolver, - "timeout": config.timeout, - "composition": config.composition, - "identity_provider": config.identity_provider, - } - if config.mode is not None: - kwargs["mode"] = config.mode - emitter = InterceptionEmitter(**kwargs) - for name, interceptor in config.interceptors: - emitter.register(interceptor, name) + if config.declaration is not None and config.registry is not None: + # One session per run, configured by the declaration: the loader rebuilds the + # sealed emitter from the same document and registry the factory validated, so + # every run gets the same resolved declaration and a fresh session (spec 7.7.7). + emitter = InterceptionEmitter.from_declaration(config.declaration, config.registry) + else: + kwargs: dict[str, Any] = { + "resolver": config.resolver, + "timeout": config.timeout, + "composition": config.composition, + "identity_provider": config.identity_provider, + } + if config.mode is not None: + kwargs["mode"] = config.mode + emitter = InterceptionEmitter(**kwargs) + for name, interceptor in config.interceptors: + emitter.register(interceptor, name) if config.record_sink is not None: emitter.set_record_sink(config.record_sink) return _RunState(emitter=emitter, builder=builder, session_scoped=False, config=config) @@ -1698,6 +1747,250 @@ def create_agent_hooks_middleware_from_emitter( return _build_bundle(config) +def _host_registry() -> HostRegistry: + from agent_hooks import HostRegistry, HostSurface + + surface = HostSurface.from_capabilities(_DECLARATION_CAPABILITIES, tool_seam_host_error=_DECLARATION_POSTURE) + return HostRegistry(surface) + + +@experimental(feature_id=ExperimentalFeature.AGENT_HOOKS) +def create_agent_hooks_host_registry() -> HostRegistry: + """Create the agent-hooks host registry for this framework's code surface. + + A host declaration (spec section 7.7) is resolved against what the host's code can honour: + its interception points, capabilities and tool-seam posture, plus the names registered in + code that a document may reference. This returns a fresh ``agent_hooks.HostRegistry`` over + the surface the agent-hooks middleware implements (all eight points; ``model_calls``, + ``tool_calls``, ``int64_json``, ``bigint_json`` and ``host_declaration``; posture + ``tool_seam_host_error: terminate``; buffered streaming). Register binding kinds, custom + identity providers, approval resolvers and approval redactors on it, then pass it as + ``registry`` to one of the ``create_agent_hooks_middleware_from_declaration*`` factories. + + Note: + A document's ``configuration.posture.tool_seam_host_error`` must equal the posture the + code implements, and the spec default is ``continue``, so every document for this + framework has to write ``"posture": {"tool_seam_host_error": "terminate"}`` or the + loader refuses it with ``declaration_error:surface_unsupported``. + + Returns: + A registry with this framework's surface and no registered names. + + Raises: + ModuleNotFoundError: If the optional ``agent-hooks-sdk`` package is not installed. + ImportError: If the installed ``agent-hooks-sdk`` predates the host-declaration loader. + """ + _require_declaration_sdk() + return _host_registry() + + +def _interceptor_kind_resolver(interceptors: Mapping[str, Interceptor]) -> Callable[[Any, BindingContext], Interceptor]: + """The resolver behind the ``agent_framework.interceptor`` kind. + + ``config`` is ``{}`` or ``{"name": ""}``; the key defaults to the binding id. Anything + else refuses the binding (``declaration_error:binding_rejected``). Messages name the binding + id (the loader prints ids itself) but never echo the config, as spec section 7.7.5 requires. + """ + + def resolve(config: Any, binding: BindingContext) -> Interceptor: + if not isinstance(config, Mapping): + raise ValueError(f"binding {binding.id!r}: config must be an object with an optional 'name' member") + members = cast("Mapping[str, Any]", config) # a JSON object: string keys + if set(members) - {"name"}: + raise ValueError(f"binding {binding.id!r}: config accepts only the 'name' member") + name: Any = members.get("name", binding.id) + if not isinstance(name, str): + raise ValueError(f"binding {binding.id!r}: config.name must be a string") + try: + return interceptors[name] + except KeyError: + raise ValueError(f"binding {binding.id!r}: no interceptor was supplied under the name it binds") from None + + return resolve + + +def _bundle_from_declaration( + declaration: HostDeclaration, + *, + interceptors: Mapping[str, Interceptor] | None, + registry: HostRegistry | None, + record_sink: Callable[[InterceptionRecord], None] | None, +) -> MiddlewareBundle: + from agent_hooks import InterceptionEmitter + + if registry is None: + registry = _host_registry() + if interceptors is not None: + registry.kind(_DECLARATION_KIND, _interceptor_kind_resolver(dict(interceptors))) + # Load once here so a refused document (every class of spec section 7.7.6, kind resolvers + # included) raises ``agent_hooks.DeclarationError`` at construction, before any run. + InterceptionEmitter.from_declaration(declaration, registry) + config = _AgentHooksConfig( + interceptors=(), + resolver=None, + mode=None, + composition=None, + identity_provider=None, + timeout=None, + record_sink=record_sink, + emitter=None, + builder=None, + declaration=declaration, + registry=registry, + ) + return _build_bundle(config) + + +@experimental(feature_id=ExperimentalFeature.AGENT_HOOKS) +def create_agent_hooks_middleware_from_declaration( + declaration: Mapping[str, Any] | HostDeclaration, + *, + interceptors: Mapping[str, Interceptor] | None = None, + registry: HostRegistry | None = None, + record_sink: Callable[[InterceptionRecord], None] | None = None, +) -> MiddlewareBundle: + """Build the agent-hooks middleware bundle from a host declaration value. + + A host declaration (spec section 7.7) is one JSON document that fixes the enforcement + configuration (mode, composition, identity provider, approval resolver and redactor, + timeouts, record bound), the declared surface and the interceptor bindings. The loader + validates it against this framework's code surface and the names in ``registry`` and + refuses anything the framework cannot honour before any emission. Enforcement semantics, + the one-session-per-run model and the composition-order requirements are those of + :func:`create_agent_hooks_middleware`; every record additionally carries the contract + version the emitter was built from (``declaration: "agent-hooks-declaration/1.0"``). + + Bindings name a kind the host registered in code; a kind is a lookup key, never a module + or class the loader imports. Passing ``interceptors`` registers the framework's own kind, + ``agent_framework.interceptor``: a binding ``{"id": "egress", "kind": + "agent_framework.interceptor"}`` runs the interceptor supplied as ``interceptors["egress"]`` + (``config: {"name": ...}`` picks a different key). Other kinds, custom identity providers, + approval resolvers and approval redactors are registered on a registry from + :func:`create_agent_hooks_host_registry` and passed in as ``registry``. + + The emitter the loader builds is sealed, so each run loads a fresh one from the same + document and registry (kind resolvers run once at construction and once per run); the + interceptors bound through ``agent_framework.interceptor`` are the supplied instances and + stay shared across runs, exactly as with :func:`create_agent_hooks_middleware`. To scope + one declaration-built session across several runs instead, build the emitter yourself + with ``agent_hooks.InterceptionEmitter.from_declaration_path`` (or ``_json`` / ``_value``) + and pass it to :func:`create_agent_hooks_middleware_from_emitter`. + + Args: + declaration: The document as a mapping (any JSON value shape), or an + ``agent_hooks.HostDeclaration`` (for example from ``agent_hooks.DeclarationBuilder``). + + Keyword Args: + interceptors: Interceptors by name for the ``agent_framework.interceptor`` kind. + ``None`` leaves the kind unregistered, so a document that uses it is refused. + registry: The host registry to resolve against; ``None`` uses a fresh registry with + this framework's surface and no registered names. The registry is used as given, + with the ``agent_framework.interceptor`` kind added when ``interceptors`` is set. + record_sink: Optional callable receiving every interception record. + + Returns: + The middleware bundle to pass (as one element) to ``Agent(middleware=...)``. + + Raises: + ModuleNotFoundError: If the optional ``agent-hooks-sdk`` package is not installed. + ImportError: If the installed ``agent-hooks-sdk`` predates the host-declaration loader. + agent_hooks.DeclarationError: If the loader refuses the document (unsupported version, + unknown member, a posture or surface the framework does not implement, an + unregistered name or kind, a binding its kind resolver rejects, and so on). + ValueError: If ``registry`` already has the ``agent_framework.interceptor`` kind while + ``interceptors`` is also given. + + Examples: + .. code-block:: python + + from agent_framework import Agent, create_agent_hooks_middleware_from_declaration + + declaration = { + "declaration": "agent-hooks-declaration/1.0", + "configuration": {"posture": {"tool_seam_host_error": "terminate"}}, + "bindings": [{"id": "egress", "kind": "agent_framework.interceptor"}], + } + agent = Agent( + client=client, + name="assistant", + middleware=[ + create_agent_hooks_middleware_from_declaration(declaration, interceptors={"egress": EgressGuard()}) + ], + ) + """ + _require_declaration_sdk() + from agent_hooks import HostDeclaration + + document = declaration if isinstance(declaration, HostDeclaration) else HostDeclaration.from_value(declaration) + return _bundle_from_declaration(document, interceptors=interceptors, registry=registry, record_sink=record_sink) + + +@experimental(feature_id=ExperimentalFeature.AGENT_HOOKS) +def create_agent_hooks_middleware_from_declaration_path( + path: str | os.PathLike[str], + *, + interceptors: Mapping[str, Interceptor] | None = None, + registry: HostRegistry | None = None, + record_sink: Callable[[InterceptionRecord], None] | None = None, +) -> MiddlewareBundle: + """Build the agent-hooks middleware bundle from a host declaration file. + + The file is read once, here, through ``agent_hooks.HostDeclaration.from_path`` (a regular + UTF-8 file of at most 1 MiB; an unreadable file is + ``agent_hooks.DeclarationError`` with class ``unreadable``). Everything else is as + :func:`create_agent_hooks_middleware_from_declaration`. + + Args: + path: The path of the declaration document. + + Keyword Args: + interceptors: See :func:`create_agent_hooks_middleware_from_declaration`. + registry: See :func:`create_agent_hooks_middleware_from_declaration`. + record_sink: Optional callable receiving every interception record. + + Returns: + The middleware bundle to pass (as one element) to ``Agent(middleware=...)``. + """ + _require_declaration_sdk() + from agent_hooks import HostDeclaration + + return _bundle_from_declaration( + HostDeclaration.from_path(path), interceptors=interceptors, registry=registry, record_sink=record_sink + ) + + +@experimental(feature_id=ExperimentalFeature.AGENT_HOOKS) +def create_agent_hooks_middleware_from_declaration_json( + json_str: str, + *, + interceptors: Mapping[str, Interceptor] | None = None, + registry: HostRegistry | None = None, + record_sink: Callable[[InterceptionRecord], None] | None = None, +) -> MiddlewareBundle: + """Build the agent-hooks middleware bundle from host declaration JSON text. + + The text goes through ``agent_hooks.HostDeclaration.from_json``; everything else is as + :func:`create_agent_hooks_middleware_from_declaration`. + + Args: + json_str: The declaration document as JSON text. + + Keyword Args: + interceptors: See :func:`create_agent_hooks_middleware_from_declaration`. + registry: See :func:`create_agent_hooks_middleware_from_declaration`. + record_sink: Optional callable receiving every interception record. + + Returns: + The middleware bundle to pass (as one element) to ``Agent(middleware=...)``. + """ + _require_declaration_sdk() + from agent_hooks import HostDeclaration + + return _bundle_from_declaration( + HostDeclaration.from_json(json_str), interceptors=interceptors, registry=registry, record_sink=record_sink + ) + + def _build_bundle(config: _AgentHooksConfig) -> MiddlewareBundle: mark_feature_used(FeatureIndex.CORE_AGENT_HOOKS) return MiddlewareBundle([ diff --git a/python/packages/core/tests/core/test_agent_hooks.py b/python/packages/core/tests/core/test_agent_hooks.py index e87193c8a52..89a0607c879 100644 --- a/python/packages/core/tests/core/test_agent_hooks.py +++ b/python/packages/core/tests/core/test_agent_hooks.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio +import json from collections.abc import AsyncIterable, Awaitable, Callable +from pathlib import Path from typing import Any, cast import pytest @@ -29,7 +31,11 @@ MiddlewareFailure, MiddlewareTermination, ResponseStream, + create_agent_hooks_host_registry, create_agent_hooks_middleware, + create_agent_hooks_middleware_from_declaration, + create_agent_hooks_middleware_from_declaration_json, + create_agent_hooks_middleware_from_declaration_path, create_agent_hooks_middleware_from_emitter, tool, ) @@ -50,9 +56,20 @@ except ImportError: # pragma: no cover - exercised on envs without the extra AGENT_HOOKS_AVAILABLE = False +try: + # The host-declaration loader ships in agent-hooks-sdk 0.1.0b2+; older SDKs lack these names. + from agent_hooks import BindingContext, DeclarationError, DeclarationErrorClass, HostDeclaration + + DECLARATION_AVAILABLE = True +except ImportError: # pragma: no cover - exercised on envs with an older SDK + DECLARATION_AVAILABLE = False + from .conftest import MockBaseChatClient requires_sdk = pytest.mark.skipif(not AGENT_HOOKS_AVAILABLE, reason="agent-hooks-sdk is not installed") +requires_declaration_sdk = pytest.mark.skipif( + not DECLARATION_AVAILABLE, reason="agent-hooks-sdk 0.1.0b2+ (host declaration support) is not installed" +) pytestmark = pytest.mark.filterwarnings("ignore::agent_framework._feature_stage.ExperimentalWarning") @@ -138,6 +155,33 @@ def points(records: list[Any]) -> list[str]: return [record.interception_point.value for record in records] +DECLARATION_VERSION = "agent-hooks-declaration/1.0" + + +def declaration_document(*bindings: dict[str, Any], **configuration: Any) -> dict[str, Any]: + """A host declaration for this framework: its tool-seam posture plus the given bindings.""" + config: dict[str, Any] = {"posture": {"tool_seam_host_error": "terminate"}, **configuration} + return {"declaration": DECLARATION_VERSION, "configuration": config, "bindings": list(bindings)} + + +def framework_binding(binding_id: str, **envelope: Any) -> dict[str, Any]: + """A binding of the framework's own kind: runs ``interceptors[config.name or id]``.""" + return {"id": binding_id, "kind": "agent_framework.interceptor", **envelope} + + +def record_summary(records: list[Any]) -> list[tuple[str, str | None, str, tuple[str, ...]]]: + """The content-bearing, session-independent part of each record.""" + return [ + ( + record.interception_point.value, + record.declaration, + record.verdict.decision.value, + tuple(verdict.name for verdict in record.verdicts), + ) + for record in records + ] + + FULL_TOOL_RUN_POINTS = [ "agent_startup", "input", @@ -157,7 +201,9 @@ def points(records: list[Any]) -> list[str]: # region Factory validation -@pytest.mark.parametrize("factory_kind", ["managed", "host_owned"]) +@pytest.mark.parametrize( + "factory_kind", ["managed", "host_owned", pytest.param("declared", marks=requires_declaration_sdk)] +) @requires_sdk def test_agent_hooks_factories_activate_feature_telemetry(factory_kind: str, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(telemetry, "_feature_mask", 0) @@ -166,6 +212,10 @@ def test_agent_hooks_factories_activate_feature_telemetry(factory_kind: str, mon if factory_kind == "managed": create_agent_hooks_middleware([AllowGuard()]) + elif factory_kind == "declared": + create_agent_hooks_middleware_from_declaration( + declaration_document(framework_binding("allow")), interceptors={"allow": AllowGuard()} + ) else: emitter = InterceptionEmitter().register(AllowGuard()) builder = AgentContextBuilder(agent_id="a", framework="agent-framework", session_id="s") @@ -3171,3 +3221,434 @@ def test_broken_sdk_installation_is_not_masked_as_missing_sdk(monkeypatch: pytes # endregion + + +# region Host declaration (spec section 7.7) + + +@requires_declaration_sdk +async def test_declaration_value_runs_one_enforced_session_per_run(chat_client_base: MockBaseChatClient) -> None: + records: list[InterceptionRecord] = [] + guard = AllowGuard() + chat_client_base.run_responses = [tool_call_response(), final_response()] + agent = Agent( + client=chat_client_base, + name="declared", + tools=[weather_tool], + middleware=[ + create_agent_hooks_middleware_from_declaration( + declaration_document(framework_binding("allow")), + interceptors={"allow": guard}, + record_sink=records.append, + ) + ], + ) + + response = await agent.run([Message(role="user", contents=["Get weather for Seattle"])]) + + assert response.text == "Final response" + assert weather_tool_calls == ["Seattle"] + assert points(records) == FULL_TOOL_RUN_POINTS + # One session per run, as with the field-based factory. + assert len({record.session_id for record in records}) == 1 + assert [record.sequence for record in records] == list(range(len(records))) + # Every record carries the contract version; the binding id is the verdict name. + assert {record.declaration for record in records} == {DECLARATION_VERSION} + assert all(record.verdicts[0].name == "allow" for record in records) + assert guard.contexts_for("pre_tool_call")[0]["tool_call"]["id"] == "call_1" + + +@requires_declaration_sdk +async def test_declaration_path_json_and_value_build_the_same_enforcement( + chat_client_base: MockBaseChatClient, tmp_path: Path +) -> None: + document = declaration_document(framework_binding("allow"), framework_binding("egress", at=["output"])) + path = tmp_path / "declaration.json" + path.write_text(json.dumps(document), encoding="utf-8") + + summaries: dict[str, list[Any]] = {} + factories: list[tuple[str, Callable[..., MiddlewareBundle]]] = [ + ("value", lambda **kw: create_agent_hooks_middleware_from_declaration(document, **kw)), + ("json", lambda **kw: create_agent_hooks_middleware_from_declaration_json(json.dumps(document), **kw)), + ("path", lambda **kw: create_agent_hooks_middleware_from_declaration_path(path, **kw)), + ] + for label, factory in factories: + records: list[InterceptionRecord] = [] + agent = Agent( + client=chat_client_base, + middleware=[ + factory(interceptors={"allow": AllowGuard(), "egress": AllowGuard()}, record_sink=records.append) + ], + ) + response = await agent.run("hello") + assert response.text == "test response - hello" + summaries[label] = record_summary(records) + + assert summaries["value"] == summaries["json"] == summaries["path"] + assert summaries["value"][0][1] == DECLARATION_VERSION + + +@requires_declaration_sdk +async def test_declaration_runs_are_separate_sessions(chat_client_base: MockBaseChatClient) -> None: + records: list[InterceptionRecord] = [] + agent = Agent( + client=chat_client_base, + middleware=[ + create_agent_hooks_middleware_from_declaration( + declaration_document(framework_binding("allow")), + interceptors={"allow": AllowGuard()}, + record_sink=records.append, + ) + ], + ) + + await agent.run("first turn") + await agent.run("second turn") + + assert ( + points(records) + == [ + "agent_startup", + "input", + "pre_model_call", + "post_model_call", + "output", + "agent_shutdown", + ] + * 2 + ) + assert len({record.session_id for record in records}) == 2 + assert [record.sequence for record in records] == [0, 1, 2, 3, 4, 5] * 2 + + +@requires_declaration_sdk +@pytest.mark.parametrize( + ("document", "error_class"), + [ + pytest.param( + {**declaration_document(), "surprise": True}, + "unknown_field", + id="unknown-top-level-member", + ), + pytest.param( + {**declaration_document(), "declaration": "agent-hooks-declaration/0.1"}, + "version_unsupported", + id="reserved-major", + ), + pytest.param( + {"declaration": DECLARATION_VERSION, "bindings": []}, + "surface_unsupported", + id="posture-defaults-to-continue", + ), + pytest.param( + declaration_document({"id": "guard", "kind": "acme.guard"}), + "kind_unknown", + id="unregistered-kind", + ), + pytest.param( + declaration_document(framework_binding("allow")), + "kind_unknown", + id="framework-kind-without-interceptors", + ), + ], +) +def test_declaration_refusal_raises_at_construction(document: dict[str, Any], error_class: str) -> None: + with pytest.raises(DeclarationError) as info: + create_agent_hooks_middleware_from_declaration(document) + + assert info.value.error_class is DeclarationErrorClass(error_class) + assert info.value.code == f"declaration_error:{error_class}" + + +@requires_declaration_sdk +def test_declaration_posture_must_name_the_framework_posture() -> None: + # The spec default is ``continue``; this framework terminates the run on a tool-seam + # host error, so a document has to say so or the loader refuses it. + with pytest.raises(DeclarationError, match="terminate") as info: + create_agent_hooks_middleware_from_declaration({"declaration": DECLARATION_VERSION, "bindings": []}) + + assert info.value.error_class is DeclarationErrorClass.SURFACE_UNSUPPORTED + assert info.value.findings[0].pointer == "/configuration/posture/tool_seam_host_error" + + +@requires_declaration_sdk +async def test_declaration_interceptor_kind_binds_by_name_or_binding_id(chat_client_base: MockBaseChatClient) -> None: + records: list[InterceptionRecord] = [] + document = declaration_document(framework_binding("first"), framework_binding("second", config={"name": "shared"})) + agent = Agent( + client=chat_client_base, + middleware=[ + create_agent_hooks_middleware_from_declaration( + document, + interceptors={"first": AllowGuard(), "shared": AllowGuard()}, + record_sink=records.append, + ) + ], + ) + + await agent.run("hello") + + assert records[0].interceptors_registered == 2 + assert [verdict.name for verdict in records[0].verdicts] == ["first", "second"] + + +@requires_declaration_sdk +@pytest.mark.parametrize( + "config", + [ + pytest.param({"name": "missing"}, id="unknown-name"), + pytest.param({"name": 3}, id="name-not-a-string"), + pytest.param({"other": 1}, id="unknown-member"), + pytest.param(["allow"], id="not-an-object"), + ], +) +def test_declaration_interceptor_kind_refuses_bad_config(config: Any) -> None: + with pytest.raises(DeclarationError) as info: + create_agent_hooks_middleware_from_declaration( + declaration_document(framework_binding("guard", config=config)), interceptors={"allow": AllowGuard()} + ) + + assert info.value.error_class is DeclarationErrorClass.BINDING_REJECTED + assert info.value.findings[0].pointer == "/bindings/0" + # The refusal names the binding but never echoes its config. + assert "guard" in str(info.value) + assert "missing" not in str(info.value) + + +@requires_declaration_sdk +async def test_declaration_with_zero_bindings_fails_closed(chat_client_base: MockBaseChatClient) -> None: + records: list[InterceptionRecord] = [] + agent = Agent( + client=chat_client_base, + middleware=[create_agent_hooks_middleware_from_declaration(declaration_document(), record_sink=records.append)], + ) + + with pytest.raises(InterceptionBlocked): + await agent.run("hello") + + assert chat_client_base.call_count == 0 + assert points(records) == ["agent_startup", "agent_shutdown"] + assert records[0].verdict.decision.value == "deny" + assert records[0].verdict.reason == "host_error:no_interceptor" + assert records[0].interceptors_registered == 0 + + +@requires_declaration_sdk +async def test_declaration_per_point_bindings_dispatch_only_where_bound(chat_client_base: MockBaseChatClient) -> None: + records: list[InterceptionRecord] = [] + egress = PointGuard("output", Verdict.deny(reason="egress_blocked")) + document = declaration_document(framework_binding("allow"), framework_binding("egress", at=["output"])) + agent = Agent( + client=chat_client_base, + middleware=[ + create_agent_hooks_middleware_from_declaration( + document, interceptors={"allow": AllowGuard(), "egress": egress}, record_sink=records.append + ) + ], + ) + + with pytest.raises(InterceptionBlocked): + await agent.run("hello") + + by_point = {record.interception_point.value: record for record in records} + assert by_point["input"].interceptors_registered == 1 + assert [verdict.name for verdict in by_point["input"].verdicts] == ["allow"] + assert by_point["output"].interceptors_registered == 2 + assert [verdict.name for verdict in by_point["output"].verdicts] == ["allow", "egress"] + assert by_point["output"].verdict.decision.value == "deny" + + +@requires_declaration_sdk +async def test_declaration_configuration_selects_evaluate_only(chat_client_base: MockBaseChatClient) -> None: + records: list[InterceptionRecord] = [] + guard = PointGuard("pre_tool_call", Verdict.deny(reason="would_block")) + chat_client_base.run_responses = [tool_call_response(), final_response()] + agent = Agent( + client=chat_client_base, + tools=[weather_tool], + middleware=[ + create_agent_hooks_middleware_from_declaration( + declaration_document(framework_binding("guard"), mode="evaluate_only"), + interceptors={"guard": guard}, + record_sink=records.append, + ) + ], + ) + + response = await agent.run("get the weather") + + assert response.text == "Final response" + assert weather_tool_calls == ["Seattle"] # evaluate_only never blocks + deny_records = [record for record in records if record.verdict.decision.value == "deny"] + assert len(deny_records) == 1 + assert deny_records[0].mode.value == "evaluate_only" + + +@requires_declaration_sdk +async def test_declaration_approval_resolver_is_resolved_by_reference(chat_client_base: MockBaseChatClient) -> None: + from agent_hooks import ApprovalOutcome, ApprovalRequest, ApprovalResolution + + class ApproveAll: + def resolve(self, request: ApprovalRequest) -> ApprovalResolution: + return ApprovalResolution( + outcome=ApprovalOutcome.APPROVE, + context_identity=request.context_identity, + verdict=ALLOW, + ) + + records: list[InterceptionRecord] = [] + guard = PointGuard("pre_tool_call", Verdict.escalate(reason="needs_approval")) + registry = create_agent_hooks_host_registry().approval_resolver("approve-all", ApproveAll()) + chat_client_base.run_responses = [tool_call_response(), final_response()] + agent = Agent( + client=chat_client_base, + tools=[weather_tool], + middleware=[ + create_agent_hooks_middleware_from_declaration( + declaration_document(framework_binding("guard"), approval={"resolver": "approve-all"}), + interceptors={"guard": guard}, + registry=registry, + record_sink=records.append, + ) + ], + ) + + response = await agent.run("get the weather") + + assert response.text == "Final response" + assert weather_tool_calls == ["Seattle"] # the lifted deny let the tool run + lifted = [record for record in records if record.resolved_by == "approval"] + assert len(lifted) == 1 + assert lifted[0].interception_point.value == "pre_tool_call" + + +@requires_declaration_sdk +async def test_declaration_host_registered_kind_receives_the_binding_context( + chat_client_base: MockBaseChatClient, +) -> None: + records: list[InterceptionRecord] = [] + seen: list[BindingContext] = [] + + def acme_guard(config: Any, binding: BindingContext) -> Any: + seen.append(binding) + return PointGuard(config["point"], Verdict.deny(reason=config["reason"])) + + registry = create_agent_hooks_host_registry().kind("acme.guard", acme_guard) + document = declaration_document({ + "id": "egress", + "kind": "acme.guard", + "config": {"point": "output", "reason": "egress_blocked"}, + "timeout_ms": 1500, + }) + agent = Agent( + client=chat_client_base, + middleware=[ + create_agent_hooks_middleware_from_declaration(document, registry=registry, record_sink=records.append) + ], + ) + + with pytest.raises(InterceptionBlocked): + await agent.run("hello") + + output = next(record for record in records if record.interception_point.value == "output") + assert output.verdict.reason == "egress_blocked" + # The resolver ran at construction (fail fast) and once for the run's session. + assert len(seen) == 2 + assert seen[0].id == "egress" + assert seen[0].kind == "acme.guard" + assert seen[0].timeout == 1.5 + assert seen[0].declaration_version == DECLARATION_VERSION + assert {point.value for point in seen[0].at} == set(FULL_TOOL_RUN_POINTS) + + +@requires_declaration_sdk +def test_declaration_registry_already_holding_the_framework_kind_is_rejected() -> None: + registry = create_agent_hooks_host_registry().kind("agent_framework.interceptor", lambda _c, _b: AllowGuard()) + + with pytest.raises(ValueError, match="registered twice"): + create_agent_hooks_middleware_from_declaration( + declaration_document(framework_binding("allow")), interceptors={"allow": AllowGuard()}, registry=registry + ) + + +@requires_declaration_sdk +def test_declaration_factories_accept_a_built_host_declaration() -> None: + from agent_hooks import DeclarationBuilder + + built: HostDeclaration = ( + DeclarationBuilder().tool_seam_host_error("terminate").bind("allow", "agent_framework.interceptor").build() + ) + + bundle = create_agent_hooks_middleware_from_declaration(built, interceptors={"allow": AllowGuard()}) + + assert isinstance(bundle, MiddlewareBundle) + + +@requires_declaration_sdk +def test_declaration_path_refuses_an_unreadable_file(tmp_path: Path) -> None: + with pytest.raises(DeclarationError) as info: + create_agent_hooks_middleware_from_declaration_path(tmp_path / "missing.json") + + assert info.value.error_class is DeclarationErrorClass.UNREADABLE + + +@requires_declaration_sdk +async def test_declaration_streaming_run_is_buffered(chat_client_base: MockBaseChatClient) -> None: + records: list[InterceptionRecord] = [] + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate(contents=[Content.from_text("Final ")], role="assistant"), + ChatResponseUpdate(contents=[Content.from_text("answer")], role="assistant", finish_reason="stop"), + ], + ] + agent = Agent( + client=chat_client_base, + middleware=[ + create_agent_hooks_middleware_from_declaration( + declaration_document(framework_binding("allow")), + interceptors={"allow": AllowGuard()}, + record_sink=records.append, + ) + ], + ) + + points_at_first_update: list[str] = [] + updates: list[AgentResponseUpdate] = [] + stream = agent.run("hello", stream=True) + async for update in stream: + if not updates: + points_at_first_update = points(records) + updates.append(update) + final = await stream.get_final_response() + + assert points_at_first_update == points(records) + assert "output" in points_at_first_update + assert "".join(update.text for update in updates) == "Final answer" + assert final.text == "Final answer" + assert {record.declaration for record in records} == {DECLARATION_VERSION} + + +@requires_declaration_sdk +async def test_declaration_built_emitter_works_with_a_host_owned_session(chat_client_base: MockBaseChatClient) -> None: + # No new factory needed for this: a sealed, declaration-built emitter is a valid host-owned + # session for the existing ``create_agent_hooks_middleware_from_emitter`` path. + records: list[InterceptionRecord] = [] + registry = create_agent_hooks_host_registry().kind("acme.allow", lambda _c, _b: AllowGuard()) + emitter = InterceptionEmitter.from_declaration_value( + declaration_document({"id": "allow", "kind": "acme.allow"}), registry + ) + emitter.set_record_sink(records.append) + builder = AgentContextBuilder(agent_id="host-agent", framework="agent-framework", session_id="session-1") + agent = Agent(client=chat_client_base, middleware=[create_agent_hooks_middleware_from_emitter(emitter, builder)]) + + await emitter.emit(builder.agent_startup(tools_registered=[])) + await agent.run("first turn") + await agent.run("second turn") + await emitter.emit(builder.agent_shutdown(reason="completed")) + + assert {record.session_id for record in records} == {"session-1"} + assert [record.sequence for record in records] == list(range(len(records))) + assert {record.declaration for record in records} == {DECLARATION_VERSION} + + +# endregion diff --git a/python/pyproject.toml b/python/pyproject.toml index 6867344c5fc..3dfb9d99727 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -39,7 +39,7 @@ dev = [ "azure-monitor-opentelemetry>=1.8.10,<2", "mcp[ws]", # Optional SDK used by the agent-hooks tests and isolated source checks. - "agent-hooks-sdk>=0.1.0a4,<0.2", + "agent-hooks-sdk>=0.1.0b2,<0.2", "opentelemetry-sdk", # Task runtime used by every supported Python version. "poethepoet==0.48.0", diff --git a/python/uv.lock b/python/uv.lock index ec75ea0ab68..523ca507d14 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -152,7 +152,7 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = " [package.metadata.requires-dev] dev = [ - { name = "agent-hooks-sdk", specifier = ">=0.1.0a4,<0.2" }, + { name = "agent-hooks-sdk", specifier = ">=0.1.0b2,<0.2" }, { name = "azure-monitor-opentelemetry", specifier = ">=1.8.10,<2" }, { name = "mcp", extras = ["ws"] }, { name = "opentelemetry-sdk" }, @@ -1080,15 +1080,15 @@ requires-dist = [ [[package]] name = "agent-hooks-sdk" -version = "0.1.0a5" +version = "0.1.0b2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/f4/191de0300a73b9a78f2914e0e497c96e6ac38db457e20f74fdd6251792c4/agent_hooks_sdk-0.1.0a5.tar.gz", hash = "sha256:4ae452b0a1d51540a1b74b0005b0a51f75fd4b80e9aca1a7403dece4dd6f9e46", size = 130183, upload-time = "2026-08-07T06:14:41.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/8c/bec0b70f321398acf05758fa267ee9dd4abc19c15ddf7e87c4530ccb48bb/agent_hooks_sdk-0.1.0b2.tar.gz", hash = "sha256:65a09039b7674e304df45aaffbbb4b2bc670faa35dd025885efba1f9954a8a92", size = 224367, upload-time = "2026-10-13T07:58:41.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/50/a6bea9691ef69be1c9c70420e84f773ee721c7e60dafc76069564ba88cc9/agent_hooks_sdk-0.1.0a5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d63893d94ccfbdc6422dd217663ed88aedec0ffd560ecfa105187fa9bda79063", size = 598919, upload-time = "2026-08-07T06:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/14/ad/fe8885b3f3f203d48dba46034d3c92dfd207a9bc504d4ba1356bf57f9234/agent_hooks_sdk-0.1.0a5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:fb1d714ed6baf884525a153821d909fb23c2fe27e63f61ae254f06ee6ec351d4", size = 570529, upload-time = "2026-08-07T06:14:36.612Z" }, - { url = "https://files.pythonhosted.org/packages/2e/6d/169997cb8ad39fc295854ed25f21171e800298e73e5a12123d3b008149f7/agent_hooks_sdk-0.1.0a5-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a0946af422eadeb3a192a7a856bc40e2b74435ab540dd3586c3cb7939157c91b", size = 626188, upload-time = "2026-08-07T06:14:37.98Z" }, - { url = "https://files.pythonhosted.org/packages/5b/22/ab5cf6e7fd26e8157637eea4b72a903d1b98dad11872857f32ead29ddcd0/agent_hooks_sdk-0.1.0a5-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a4b53cce9dabf1ee8ffffa4b5f6c6d34d269daa699d468e49fad286f0070d23f", size = 649025, upload-time = "2026-08-07T06:14:39.218Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c2/d2bb3772abe3b04fc841bc6c667f49adf9ae037c945cbe0d6a8739d419c4/agent_hooks_sdk-0.1.0a5-cp310-abi3-win_amd64.whl", hash = "sha256:777a4cd3803710277e77d9c6abb6d1a2c9d6ce8977167fe47fc57b8e4ff8bc75", size = 479891, upload-time = "2026-08-07T06:14:40.697Z" }, + { url = "https://files.pythonhosted.org/packages/ee/60/db0afa1fc87a0ab21669085a5d1f7dfa05dba2c1ce7a8e4eb54e3d99ae4c/agent_hooks_sdk-0.1.0b2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:41a941d014baeaa9a1a2851264bba9ee72492d70800a741cc3ad688eabe933cf", size = 777424, upload-time = "2026-10-13T07:58:33.791Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9da81e5e1ce44314cf6817b111e07db1df2934e5cc680384183fd088e200/agent_hooks_sdk-0.1.0b2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:04174e1045e8142086f358e4023229cbf36a25594fd4c4b9f49d7c54e48a668c", size = 738082, upload-time = "2026-10-13T07:58:35.372Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/d26203019c0ec167552c9a2770e2e03fffc851eb74578592de2dca5f1c1b/agent_hooks_sdk-0.1.0b2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ad927aa9c0a9a8c523fd2bfa2d331b51c5cef2a4c5c3a7a8f0a0d313a456d3c2", size = 797985, upload-time = "2026-10-13T07:58:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/44/6b/a47e684d828ad0e1fbd8db729b7cd215858cfa30d4307e750b19a25e45d0/agent_hooks_sdk-0.1.0b2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f074282bafe9f470141aff9b92d82986f0cdfd2d56056754ff42ae0e0c916f5", size = 837037, upload-time = "2026-10-13T07:58:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/75/56/b56f9f2531792d5dc4f0a5fbcff6ac96223b5cca7eedb94981f89eefda33/agent_hooks_sdk-0.1.0b2-cp310-abi3-win_amd64.whl", hash = "sha256:30ff4fcc9c532ac018e6d9c13a6750f3029312fe4e953849dae40f177c78a6ef", size = 655970, upload-time = "2026-10-13T07:58:40.431Z" }, ] [[package]] From 38f3e4e3503a2b931238e6a8cf55c796f68fb13b Mon Sep 17 00:00:00 2001 From: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:00:22 +0000 Subject: [PATCH 2/3] Python: fix(core): validate caller registries against the middleware surface and accept any Mapping The declaration factories handed a caller-supplied registry to the SDK as given, and the SDK validates a document against that registry's own surface. A registry built over another surface (the SDK default, with posture continue, or one that streams unbuffered) therefore accepted documents this middleware cannot honour: a document that omits the posture, or one that declares buffered_output: false. The factories now refuse a registry whose surface differs from the one the middleware implements, with a ValueError that names the differing members and points at create_agent_hooks_host_registry(). A registry over the same surface keeps its registered kinds, identity providers, approval resolvers and redactors, so a document is always checked against the middleware's surface and the caller's names. create_agent_hooks_middleware_from_declaration accepts a Mapping, but HostDeclaration.from_value serializes with json.dumps, which takes a dict and refuses other mappings such as MappingProxyType or UserDict with declaration_error:malformed. The factory now copies a top-level Mapping into a dict before handing it to the SDK. Anything that is not a mapping still reaches the SDK as given, so its own refusal applies. Tests cover a registry with another posture, one that advertises unbuffered streaming, a hand-built registry over the framework surface that keeps its registered names, MappingProxyType and UserDict documents, and non-mapping values that get the SDK's refusal. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> --- .../core/agent_framework/_agent_hooks.py | 67 +++++++-- .../core/tests/core/test_agent_hooks.py | 135 +++++++++++++++++- 2 files changed, 191 insertions(+), 11 deletions(-) diff --git a/python/packages/core/agent_framework/_agent_hooks.py b/python/packages/core/agent_framework/_agent_hooks.py index 753fd124699..299d9d98a03 100644 --- a/python/packages/core/agent_framework/_agent_hooks.py +++ b/python/packages/core/agent_framework/_agent_hooks.py @@ -154,6 +154,7 @@ EnforcementMode, HostDeclaration, HostRegistry, + HostSurface, IdentityProvider, InterceptionBlocked, InterceptionEmitter, @@ -1747,11 +1748,39 @@ def create_agent_hooks_middleware_from_emitter( return _build_bundle(config) +def _host_surface() -> HostSurface: + from agent_hooks import HostSurface + + return HostSurface.from_capabilities(_DECLARATION_CAPABILITIES, tool_seam_host_error=_DECLARATION_POSTURE) + + def _host_registry() -> HostRegistry: - from agent_hooks import HostRegistry, HostSurface + from agent_hooks import HostRegistry + + return HostRegistry(_host_surface()) + + +def _check_registry_surface(registry: HostRegistry) -> None: + """Refuse a registry whose surface is not the one this middleware implements. - surface = HostSurface.from_capabilities(_DECLARATION_CAPABILITIES, tool_seam_host_error=_DECLARATION_POSTURE) - return HostRegistry(surface) + The loader validates a document against ``registry.surface``, so a registry over another + surface (the SDK default with posture ``continue``, or one that streams unbuffered) would + accept a document this middleware cannot honour. That is a programming error of the host, + not a refusal of the document, so it is a ``ValueError`` like the SDK's own registry checks. + """ + expected = _host_surface().to_wire() + actual = registry.surface.to_wire() + if actual == expected: + return + differences = ", ".join( + f"{member}={actual.get(member)!r} (this middleware implements {expected.get(member)!r})" + for member in sorted(expected.keys() | actual.keys()) + if actual.get(member) != expected.get(member) + ) + raise ValueError( + f"registry surface differs from the surface this middleware implements: {differences}. " + "Build the registry with create_agent_hooks_host_registry()." + ) @experimental(feature_id=ExperimentalFeature.AGENT_HOOKS) @@ -1766,6 +1795,8 @@ def create_agent_hooks_host_registry() -> HostRegistry: ``tool_seam_host_error: terminate``; buffered streaming). Register binding kinds, custom identity providers, approval resolvers and approval redactors on it, then pass it as ``registry`` to one of the ``create_agent_hooks_middleware_from_declaration*`` factories. + Those factories refuse a registry over any other surface (``ValueError``), so a document is + always checked against the surface the code implements. Note: A document's ``configuration.posture.tool_seam_host_error`` must equal the posture the @@ -1820,6 +1851,8 @@ def _bundle_from_declaration( if registry is None: registry = _host_registry() + else: + _check_registry_surface(registry) if interceptors is not None: registry.kind(_DECLARATION_KIND, _interceptor_kind_resolver(dict(interceptors))) # Load once here so a refused document (every class of spec section 7.7.6, kind resolvers @@ -1877,15 +1910,19 @@ def create_agent_hooks_middleware_from_declaration( and pass it to :func:`create_agent_hooks_middleware_from_emitter`. Args: - declaration: The document as a mapping (any JSON value shape), or an - ``agent_hooks.HostDeclaration`` (for example from ``agent_hooks.DeclarationBuilder``). + declaration: The document as a mapping (any ``Mapping``; it is copied into a ``dict`` for + the SDK and its members must be JSON values), or an ``agent_hooks.HostDeclaration`` + (for example from ``agent_hooks.DeclarationBuilder``). Keyword Args: interceptors: Interceptors by name for the ``agent_framework.interceptor`` kind. ``None`` leaves the kind unregistered, so a document that uses it is refused. registry: The host registry to resolve against; ``None`` uses a fresh registry with - this framework's surface and no registered names. The registry is used as given, - with the ``agent_framework.interceptor`` kind added when ``interceptors`` is set. + this framework's surface and no registered names. A given registry must carry that + same surface (build it with :func:`create_agent_hooks_host_registry`), so the + document is always checked against what this middleware implements; its registered + names are used as given, with the ``agent_framework.interceptor`` kind added when + ``interceptors`` is set. record_sink: Optional callable receiving every interception record. Returns: @@ -1897,8 +1934,9 @@ def create_agent_hooks_middleware_from_declaration( agent_hooks.DeclarationError: If the loader refuses the document (unsupported version, unknown member, a posture or surface the framework does not implement, an unregistered name or kind, a binding its kind resolver rejects, and so on). - ValueError: If ``registry`` already has the ``agent_framework.interceptor`` kind while - ``interceptors`` is also given. + ValueError: If ``registry`` was built over a surface other than this framework's, or + already has the ``agent_framework.interceptor`` kind while ``interceptors`` is also + given. Examples: .. code-block:: python @@ -1921,7 +1959,16 @@ def create_agent_hooks_middleware_from_declaration( _require_declaration_sdk() from agent_hooks import HostDeclaration - document = declaration if isinstance(declaration, HostDeclaration) else HostDeclaration.from_value(declaration) + if isinstance(declaration, HostDeclaration): + document = declaration + else: + # ``HostDeclaration.from_value`` serializes with ``json.dumps``, which takes a ``dict`` but no + # other ``Mapping`` (``MappingProxyType``, ``UserDict``). Anything that is not a mapping goes + # through as given, so the SDK's own refusal applies (``malformed``: the root is not an object). + value: Any = declaration + if isinstance(value, Mapping): + value = dict(cast("Mapping[str, Any]", value)) + document = HostDeclaration.from_value(value) return _bundle_from_declaration(document, interceptors=interceptors, registry=registry, record_sink=record_sink) diff --git a/python/packages/core/tests/core/test_agent_hooks.py b/python/packages/core/tests/core/test_agent_hooks.py index 89a0607c879..2ef60214396 100644 --- a/python/packages/core/tests/core/test_agent_hooks.py +++ b/python/packages/core/tests/core/test_agent_hooks.py @@ -4,8 +4,11 @@ import asyncio import json -from collections.abc import AsyncIterable, Awaitable, Callable +from collections import UserDict +from collections.abc import AsyncIterable, Awaitable, Callable, Mapping +from dataclasses import replace from pathlib import Path +from types import MappingProxyType from typing import Any, cast import pytest @@ -3571,6 +3574,136 @@ def test_declaration_registry_already_holding_the_framework_kind_is_rejected() - ) +@requires_declaration_sdk +def test_declaration_registry_over_another_posture_is_refused() -> None: + from agent_hooks import HostRegistry + + # The SDK validates a document against the registry's own surface. This registry says + # ``continue``, so the SDK alone accepts a document that omits the posture; the middleware + # terminates the run on a tool-seam host error and cannot honour that document. + registry = HostRegistry(replace(create_agent_hooks_host_registry().surface, tool_seam_host_error="continue")) + document = {"declaration": DECLARATION_VERSION, "bindings": []} + assert registry.resolve(HostDeclaration.from_value(document)).declaration == DECLARATION_VERSION + + with pytest.raises(ValueError, match="tool_seam_host_error='continue'") as info: + create_agent_hooks_middleware_from_declaration(document, registry=registry) + + # A programming error of the host, not a refusal of the document. + assert not isinstance(info.value, DeclarationError) + assert "create_agent_hooks_host_registry" in str(info.value) + + +@requires_declaration_sdk +def test_declaration_registry_streaming_unbuffered_is_refused() -> None: + from agent_hooks import HostRegistry + + # This middleware buffers a streaming run until the output point is judged. A registry that + # advertises unbuffered streaming lets the SDK accept ``buffered_output: false``, which the + # middleware would then not honour. + surface = create_agent_hooks_host_registry().surface.with_capabilities(["incremental_output"]) + registry = HostRegistry(surface.with_exposure_bound("one-chunk")) + document = {**declaration_document(), "surface": {"buffered_output": False, "exposure_bound": "one-chunk"}} + assert registry.resolve(HostDeclaration.from_value(document)).declaration == DECLARATION_VERSION + + with pytest.raises(ValueError, match="streams_unbuffered=True") as info: + create_agent_hooks_middleware_from_declaration(document, registry=registry) + + assert not isinstance(info.value, DeclarationError) + + +@requires_declaration_sdk +async def test_declaration_compatible_custom_registry_keeps_its_registered_names( + chat_client_base: MockBaseChatClient, +) -> None: + from agent_hooks import HostRegistry + + # A registry built by hand over the framework's surface passes the surface check, and the + # kind and identity provider registered on it stay available to the document. + records: list[InterceptionRecord] = [] + identities: list[str] = [] + + def acme_identity(_context: Any) -> str: + identities.append("fixed-identity") + return identities[-1] + + registry = ( + HostRegistry(create_agent_hooks_host_registry().surface) + .kind("acme.allow", lambda _c, _b: AllowGuard()) + .identity_provider("acme-id", acme_identity) + ) + document = declaration_document({"id": "allow", "kind": "acme.allow"}, identity_provider="acme-id") + agent = Agent( + client=chat_client_base, + middleware=[ + create_agent_hooks_middleware_from_declaration(document, registry=registry, record_sink=records.append) + ], + ) + + response = await agent.run("hello") + + assert response.text == "test response - hello" + assert points(records) == [ + "agent_startup", + "input", + "pre_model_call", + "post_model_call", + "output", + "agent_shutdown", + ] + assert all(record.verdicts[0].name == "allow" for record in records) + assert {record.identity_provider for record in records} == {"acme-id"} + assert {record.input_identity for record in records} == {"fixed-identity"} + assert len(identities) >= len(records) + + +@requires_declaration_sdk +@pytest.mark.parametrize( + "document", + [ + pytest.param(MappingProxyType(declaration_document(framework_binding("allow"))), id="mappingproxy"), + pytest.param(UserDict(declaration_document(framework_binding("allow"))), id="userdict"), + ], +) +async def test_declaration_value_accepts_any_mapping( + document: Mapping[str, Any], chat_client_base: MockBaseChatClient +) -> None: + # ``HostDeclaration.from_value`` serializes with ``json.dumps``, which takes a ``dict`` but no + # other ``Mapping``; the factory copies the top-level mapping so the advertised type works. + records: list[InterceptionRecord] = [] + agent = Agent( + client=chat_client_base, + middleware=[ + create_agent_hooks_middleware_from_declaration( + document, interceptors={"allow": AllowGuard()}, record_sink=records.append + ) + ], + ) + + response = await agent.run("hello") + + assert response.text == "test response - hello" + assert {record.declaration for record in records} == {DECLARATION_VERSION} + + +@requires_declaration_sdk +@pytest.mark.parametrize( + "value", + [ + pytest.param(["not", "a", "document"], id="list"), + pytest.param("not a document", id="string"), + pytest.param(None, id="null"), + ], +) +def test_declaration_non_mapping_value_gets_the_sdk_refusal(value: Any) -> None: + # Only a top-level mapping is copied; anything else reaches the SDK as given and gets its + # own refusal, at the root pointer. + with pytest.raises(DeclarationError) as info: + create_agent_hooks_middleware_from_declaration(value) + + assert info.value.error_class is DeclarationErrorClass.MALFORMED + assert info.value.findings[0].pointer == "" + + @requires_declaration_sdk def test_declaration_factories_accept_a_built_host_declaration() -> None: from agent_hooks import DeclarationBuilder From 082a8ff176dde6d8faf220871aa0914e4e959085 Mon Sep 17 00:00:00 2001 From: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:35:54 +0000 Subject: [PATCH 3/3] Python: refactor(core): one declaration factory, per-call registry Review of the host-declaration factories asked for two changes. The helpers had grown to five or six functions that did roughly the same thing, and the shared helper registered agent_framework.interceptor on the caller-supplied HostRegistry before validation. HostRegistry.kind() mutates in place, so a refused document or a raising kind resolver left the caller's registry holding the kind: the next factory call failed as already registered, or reused the first call's resolver. create_agent_hooks_middleware_from_declaration is now the only declaration factory. It accepts a built HostDeclaration, any Mapping (copied into a dict for the SDK), or a file path as str or os.PathLike, read once through HostDeclaration.from_path. JSON text is not accepted directly; HostDeclaration.from_json covers it. The registry keyword is replaced by configure_registry, a callback that receives a fresh HostRegistry built for that call over this framework's surface, after agent_framework.interceptor is registered when interceptors is given and before the document is validated. The registry never leaves the factory and nothing caller-owned is mutated, so a refused document or a raising callback cannot affect a later call. A callback that registers agent_framework.interceptor while interceptors is also given hits the SDK's own "registered twice" ValueError. The callback's return value is ignored (Callable[[HostRegistry], object]), so a lambda over the chaining registry methods works. A document with no bindings is refused at construction with ValueError, the rule create_agent_hooks_middleware already applies, since an emitter with zero interceptors fails closed on every emission. This mirrors the .NET factories, which take Action configureRegistry and refuse zero bindings the same way. Removed: create_agent_hooks_middleware_from_declaration_path, create_agent_hooks_middleware_from_declaration_json and create_agent_hooks_host_registry, with the surface check they needed, from the module, the lazy export map, the stub and PACKAGE_STATUS.md. Tests cover reuse of one callback after a refused document and after the callback itself raised, two factories sharing one callback and one interceptors mapping, the registered-twice clash, str and os.PathLike paths, a missing file, JSON text given as a path, a lambda callback, a document with no bindings, and a kind, identity provider, approval resolver and redactor registered through the callback. The per-run reload and the from_emitter single-session tests stay. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> --- python/PACKAGE_STATUS.md | 9 +- .../packages/core/agent_framework/__init__.py | 6 - .../core/agent_framework/__init__.pyi | 6 - .../core/agent_framework/_agent_hooks.py | 292 +++++------- .../core/tests/core/test_agent_hooks.py | 436 ++++++++++++++---- 5 files changed, 455 insertions(+), 294 deletions(-) diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 7a9c2ee48e9..3324b40efda 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -83,16 +83,13 @@ listed below. #### `AGENT_HOOKS` - `agent-framework-core`: `create_agent_hooks_middleware`, - `create_agent_hooks_middleware_from_emitter`, the host-declaration factories - `create_agent_hooks_middleware_from_declaration`, - `create_agent_hooks_middleware_from_declaration_path` and - `create_agent_hooks_middleware_from_declaration_json`, and - `create_agent_hooks_host_registry` from `agent_framework/_agent_hooks.py`, + `create_agent_hooks_middleware_from_emitter` and the host-declaration factory + `create_agent_hooks_middleware_from_declaration` from `agent_framework/_agent_hooks.py`, the AGENT-HOOKS-0.1 enforcement middleware bundle, and the `MiddlewareBundle` container from `agent_framework/_middleware.py` that the factories produce (`MiddlewareBundle` itself needs no optional dependency). Requires installing the `agent-hooks-sdk` package separately (0.1.0b2 or later for the host-declaration - factories). Known limitation: service-side (hosted) tool + factory). Known limitation: service-side (hosted) tool execution never passes through the framework's function-invocation seam, so the `pre_tool_call`/`post_tool_call` points cannot intercept it; hosted tool calls and outputs are surfaced in the `post_model_call` content projection instead. diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index e68fe1de1da..8132f659c9e 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -43,11 +43,8 @@ _LAZY_MODULE_EXPORTS: Final[Mapping[str, tuple[str, ...]]] = { "._agent_hooks": ( - "create_agent_hooks_host_registry", "create_agent_hooks_middleware", "create_agent_hooks_middleware_from_declaration", - "create_agent_hooks_middleware_from_declaration_json", - "create_agent_hooks_middleware_from_declaration_path", "create_agent_hooks_middleware_from_emitter", ), "._agents": ("Agent", "BaseAgent", "RawAgent", "SupportsAgentRun"), @@ -688,11 +685,8 @@ "background_tasks_running", "background_tasks_running_message", "chat_middleware", - "create_agent_hooks_host_registry", "create_agent_hooks_middleware", "create_agent_hooks_middleware_from_declaration", - "create_agent_hooks_middleware_from_declaration_json", - "create_agent_hooks_middleware_from_declaration_path", "create_agent_hooks_middleware_from_emitter", "create_always_approve_tool_response", "create_always_approve_tool_with_arguments_response", diff --git a/python/packages/core/agent_framework/__init__.pyi b/python/packages/core/agent_framework/__init__.pyi index eef684da1c7..9a7e0fdfade 100644 --- a/python/packages/core/agent_framework/__init__.pyi +++ b/python/packages/core/agent_framework/__init__.pyi @@ -5,11 +5,8 @@ from typing import Final __version__: Final[str] from ._agent_hooks import ( - create_agent_hooks_host_registry, create_agent_hooks_middleware, create_agent_hooks_middleware_from_declaration, - create_agent_hooks_middleware_from_declaration_json, - create_agent_hooks_middleware_from_declaration_path, create_agent_hooks_middleware_from_emitter, ) from ._agents import Agent, BaseAgent, RawAgent, SupportsAgentRun @@ -643,11 +640,8 @@ __all__ = [ "background_tasks_running", "background_tasks_running_message", "chat_middleware", - "create_agent_hooks_host_registry", "create_agent_hooks_middleware", "create_agent_hooks_middleware_from_declaration", - "create_agent_hooks_middleware_from_declaration_json", - "create_agent_hooks_middleware_from_declaration_path", "create_agent_hooks_middleware_from_emitter", "create_always_approve_tool_response", "create_always_approve_tool_with_arguments_response", diff --git a/python/packages/core/agent_framework/_agent_hooks.py b/python/packages/core/agent_framework/_agent_hooks.py index 299d9d98a03..c3e122f2d5c 100644 --- a/python/packages/core/agent_framework/_agent_hooks.py +++ b/python/packages/core/agent_framework/_agent_hooks.py @@ -11,18 +11,16 @@ The public entry points are :func:`create_agent_hooks_middleware` (one agent-hooks session per run), :func:`create_agent_hooks_middleware_from_emitter` (host-owned -session) and the host-declaration factories -:func:`create_agent_hooks_middleware_from_declaration`, -:func:`create_agent_hooks_middleware_from_declaration_path` and -:func:`create_agent_hooks_middleware_from_declaration_json` (one session per run, with the -configuration and the interceptor bindings read from a host declaration document, spec -section 7.7). All return a :class:`~agent_framework.MiddlewareBundle`: the three middleware -implementations are deliberately private and travel as one indivisible unit, because -installing only part of them would enforce only part of the control contract. Install -exactly one bundle per agent, placed first in the middleware list: middleware listed -before the bundle runs outside the enforcement boundary (outer position is outer trust) -— e.g. a function middleware placed before the bundle can substitute a tool result that -the tool seam never brackets; the final ``output`` point still guards whatever egresses. +session) and :func:`create_agent_hooks_middleware_from_declaration` (one session per +run, with the configuration and the interceptor bindings read from a host declaration +document, spec section 7.7). All return a :class:`~agent_framework.MiddlewareBundle`: the +three middleware implementations are deliberately private and travel as one indivisible +unit, because installing only part of them would enforce only part of the control +contract. Install exactly one bundle per agent, placed first in the middleware list: +middleware listed before the bundle runs outside the enforcement boundary (outer position +is outer trust) — e.g. a function middleware placed before the bundle can substitute a +tool result that the tool seam never brackets; the final ``output`` point still guards +whatever egresses. Enforcement semantics (``mode="enforce"``): @@ -268,7 +266,7 @@ class _AgentHooksConfig: record_sink: Callable[[InterceptionRecord], None] | None emitter: InterceptionEmitter | None builder: AgentContextBuilder | None - # Set by the host-declaration factories: every run loads a fresh emitter from this pair. + # Set by the host-declaration factory: every run loads a fresh emitter from this pair. declaration: HostDeclaration | None = None registry: HostRegistry | None = None @@ -1755,66 +1753,12 @@ def _host_surface() -> HostSurface: def _host_registry() -> HostRegistry: + """A fresh registry over this framework's surface (the declaration factory builds one per call).""" from agent_hooks import HostRegistry return HostRegistry(_host_surface()) -def _check_registry_surface(registry: HostRegistry) -> None: - """Refuse a registry whose surface is not the one this middleware implements. - - The loader validates a document against ``registry.surface``, so a registry over another - surface (the SDK default with posture ``continue``, or one that streams unbuffered) would - accept a document this middleware cannot honour. That is a programming error of the host, - not a refusal of the document, so it is a ``ValueError`` like the SDK's own registry checks. - """ - expected = _host_surface().to_wire() - actual = registry.surface.to_wire() - if actual == expected: - return - differences = ", ".join( - f"{member}={actual.get(member)!r} (this middleware implements {expected.get(member)!r})" - for member in sorted(expected.keys() | actual.keys()) - if actual.get(member) != expected.get(member) - ) - raise ValueError( - f"registry surface differs from the surface this middleware implements: {differences}. " - "Build the registry with create_agent_hooks_host_registry()." - ) - - -@experimental(feature_id=ExperimentalFeature.AGENT_HOOKS) -def create_agent_hooks_host_registry() -> HostRegistry: - """Create the agent-hooks host registry for this framework's code surface. - - A host declaration (spec section 7.7) is resolved against what the host's code can honour: - its interception points, capabilities and tool-seam posture, plus the names registered in - code that a document may reference. This returns a fresh ``agent_hooks.HostRegistry`` over - the surface the agent-hooks middleware implements (all eight points; ``model_calls``, - ``tool_calls``, ``int64_json``, ``bigint_json`` and ``host_declaration``; posture - ``tool_seam_host_error: terminate``; buffered streaming). Register binding kinds, custom - identity providers, approval resolvers and approval redactors on it, then pass it as - ``registry`` to one of the ``create_agent_hooks_middleware_from_declaration*`` factories. - Those factories refuse a registry over any other surface (``ValueError``), so a document is - always checked against the surface the code implements. - - Note: - A document's ``configuration.posture.tool_seam_host_error`` must equal the posture the - code implements, and the spec default is ``continue``, so every document for this - framework has to write ``"posture": {"tool_seam_host_error": "terminate"}`` or the - loader refuses it with ``declaration_error:surface_unsupported``. - - Returns: - A registry with this framework's surface and no registered names. - - Raises: - ModuleNotFoundError: If the optional ``agent-hooks-sdk`` package is not installed. - ImportError: If the installed ``agent-hooks-sdk`` predates the host-declaration loader. - """ - _require_declaration_sdk() - return _host_registry() - - def _interceptor_kind_resolver(interceptors: Mapping[str, Interceptor]) -> Callable[[Any, BindingContext], Interceptor]: """The resolver behind the ``agent_framework.interceptor`` kind. @@ -1840,54 +1784,20 @@ def resolve(config: Any, binding: BindingContext) -> Interceptor: return resolve -def _bundle_from_declaration( - declaration: HostDeclaration, - *, - interceptors: Mapping[str, Interceptor] | None, - registry: HostRegistry | None, - record_sink: Callable[[InterceptionRecord], None] | None, -) -> MiddlewareBundle: - from agent_hooks import InterceptionEmitter - - if registry is None: - registry = _host_registry() - else: - _check_registry_surface(registry) - if interceptors is not None: - registry.kind(_DECLARATION_KIND, _interceptor_kind_resolver(dict(interceptors))) - # Load once here so a refused document (every class of spec section 7.7.6, kind resolvers - # included) raises ``agent_hooks.DeclarationError`` at construction, before any run. - InterceptionEmitter.from_declaration(declaration, registry) - config = _AgentHooksConfig( - interceptors=(), - resolver=None, - mode=None, - composition=None, - identity_provider=None, - timeout=None, - record_sink=record_sink, - emitter=None, - builder=None, - declaration=declaration, - registry=registry, - ) - return _build_bundle(config) - - @experimental(feature_id=ExperimentalFeature.AGENT_HOOKS) def create_agent_hooks_middleware_from_declaration( - declaration: Mapping[str, Any] | HostDeclaration, + declaration: HostDeclaration | Mapping[str, Any] | str | os.PathLike[str], *, interceptors: Mapping[str, Interceptor] | None = None, - registry: HostRegistry | None = None, + configure_registry: Callable[[HostRegistry], object] | None = None, record_sink: Callable[[InterceptionRecord], None] | None = None, ) -> MiddlewareBundle: - """Build the agent-hooks middleware bundle from a host declaration value. + """Build the agent-hooks middleware bundle from a host declaration. A host declaration (spec section 7.7) is one JSON document that fixes the enforcement configuration (mode, composition, identity provider, approval resolver and redactor, timeouts, record bound), the declared surface and the interceptor bindings. The loader - validates it against this framework's code surface and the names in ``registry`` and + validates it against this framework's code surface and the names registered in code, and refuses anything the framework cannot honour before any emission. Enforcement semantics, the one-session-per-run model and the composition-order requirements are those of :func:`create_agent_hooks_middleware`; every record additionally carries the contract @@ -1898,8 +1808,14 @@ def create_agent_hooks_middleware_from_declaration( ``agent_framework.interceptor``: a binding ``{"id": "egress", "kind": "agent_framework.interceptor"}`` runs the interceptor supplied as ``interceptors["egress"]`` (``config: {"name": ...}`` picks a different key). Other kinds, custom identity providers, - approval resolvers and approval redactors are registered on a registry from - :func:`create_agent_hooks_host_registry` and passed in as ``registry``. + approval resolvers and approval redactors are registered through ``configure_registry``. + Every call builds a fresh ``agent_hooks.HostRegistry`` over the surface this middleware + implements (all eight points; ``model_calls``, ``tool_calls``, ``int64_json``, + ``bigint_json`` and ``host_declaration``; posture ``tool_seam_host_error: terminate``; + buffered streaming), registers ``agent_framework.interceptor`` when ``interceptors`` is + given, hands the registry to ``configure_registry``, then validates the document against + it. The registry never leaves the factory and nothing caller-owned is touched, so a + refused document or a raising callback leaves nothing behind for the next call. The emitter the loader builds is sealed, so each run loads a fresh one from the same document and registry (kind resolvers run once at construction and once per run); the @@ -1909,20 +1825,35 @@ def create_agent_hooks_middleware_from_declaration( with ``agent_hooks.InterceptionEmitter.from_declaration_path`` (or ``_json`` / ``_value``) and pass it to :func:`create_agent_hooks_middleware_from_emitter`. + Note: + A document's ``configuration.posture.tool_seam_host_error`` must equal the posture the + code implements, and the spec default is ``continue``, so every document for this + framework has to write ``"posture": {"tool_seam_host_error": "terminate"}`` or the + loader refuses it with ``declaration_error:surface_unsupported``. + Args: - declaration: The document as a mapping (any ``Mapping``; it is copied into a ``dict`` for - the SDK and its members must be JSON values), or an ``agent_hooks.HostDeclaration`` - (for example from ``agent_hooks.DeclarationBuilder``). + declaration: The document, in one of three forms. An ``agent_hooks.HostDeclaration`` + (for example from ``agent_hooks.DeclarationBuilder``, or + ``agent_hooks.HostDeclaration.from_json(text)`` for JSON text you already hold) is + used as is. A mapping (any ``Mapping``; it is copied into a ``dict`` for the SDK and + its members must be JSON values) is the document as a value. A ``str`` or + ``os.PathLike`` is the path of the document file, read once through + ``agent_hooks.HostDeclaration.from_path`` (a regular UTF-8 file of at most 1 MiB; an + unreadable file is ``agent_hooks.DeclarationError`` with class ``unreadable``). + JSON text is not accepted directly: a string is always a path. Keyword Args: interceptors: Interceptors by name for the ``agent_framework.interceptor`` kind. ``None`` leaves the kind unregistered, so a document that uses it is refused. - registry: The host registry to resolve against; ``None`` uses a fresh registry with - this framework's surface and no registered names. A given registry must carry that - same surface (build it with :func:`create_agent_hooks_host_registry`), so the - document is always checked against what this middleware implements; its registered - names are used as given, with the ``agent_framework.interceptor`` kind added when - ``interceptors`` is set. + configure_registry: Called at most once, with the fresh ``agent_hooks.HostRegistry`` + built for this call, to register the host's own binding kinds, identity providers, + approval resolvers and approval redactors (``registry.kind(...)``, + ``registry.identity_provider(...)``, ``registry.approval_resolver(...)``, + ``registry.approval_redactor(...)``) before the document is validated. A file that + cannot be read (class ``unreadable``) or a value that cannot be serialized (class + ``malformed``) is refused before the callback runs. ``None`` registers nothing of + the host's own. A callback that raises propagates as is; nothing is cached, so the + next call starts from a fresh registry. record_sink: Optional callable receiving every interception record. Returns: @@ -1931,12 +1862,15 @@ def create_agent_hooks_middleware_from_declaration( Raises: ModuleNotFoundError: If the optional ``agent-hooks-sdk`` package is not installed. ImportError: If the installed ``agent-hooks-sdk`` predates the host-declaration loader. - agent_hooks.DeclarationError: If the loader refuses the document (unsupported version, - unknown member, a posture or surface the framework does not implement, an - unregistered name or kind, a binding its kind resolver rejects, and so on). - ValueError: If ``registry`` was built over a surface other than this framework's, or - already has the ``agent_framework.interceptor`` kind while ``interceptors`` is also - given. + agent_hooks.DeclarationError: If the loader refuses the document (an unreadable file, + unsupported version, unknown member, a posture or surface the framework does not + implement, an unregistered name or kind, a binding its kind resolver rejects, and + so on). + ValueError: If ``configure_registry`` registers ``agent_framework.interceptor`` while + ``interceptors`` is also given, or registers any other name twice or under the + wrong grammar (the SDK's own registry checks, raised as they are); or if the + document declares no bindings, since an emitter with zero interceptors fails closed + on every emission (the same rule as :func:`create_agent_hooks_middleware`). Examples: .. code-block:: python @@ -1955,12 +1889,27 @@ def create_agent_hooks_middleware_from_declaration( create_agent_hooks_middleware_from_declaration(declaration, interceptors={"egress": EgressGuard()}) ], ) + + A host kind registered through ``configure_registry``: the document is read once from + the file and binds ``{"id": "guard", "kind": "acme.guard", "config": {...}}``. The + callback's return value is ignored, so a one-line lambda over the chaining registry + methods works: + + .. code-block:: python + + bundle = create_agent_hooks_middleware_from_declaration( + "policy/declaration.json", + configure_registry=lambda registry: registry.kind("acme.guard", acme_guard), + ) """ _require_declaration_sdk() - from agent_hooks import HostDeclaration + from agent_hooks import HostDeclaration, InterceptionEmitter if isinstance(declaration, HostDeclaration): document = declaration + elif isinstance(declaration, (str, os.PathLike)): + # A string is a path, never JSON text (``HostDeclaration.from_json`` is the text path). + document = HostDeclaration.from_path(declaration) else: # ``HostDeclaration.from_value`` serializes with ``json.dumps``, which takes a ``dict`` but no # other ``Mapping`` (``MappingProxyType``, ``UserDict``). Anything that is not a mapping goes @@ -1969,73 +1918,38 @@ def create_agent_hooks_middleware_from_declaration( if isinstance(value, Mapping): value = dict(cast("Mapping[str, Any]", value)) document = HostDeclaration.from_value(value) - return _bundle_from_declaration(document, interceptors=interceptors, registry=registry, record_sink=record_sink) - -@experimental(feature_id=ExperimentalFeature.AGENT_HOOKS) -def create_agent_hooks_middleware_from_declaration_path( - path: str | os.PathLike[str], - *, - interceptors: Mapping[str, Interceptor] | None = None, - registry: HostRegistry | None = None, - record_sink: Callable[[InterceptionRecord], None] | None = None, -) -> MiddlewareBundle: - """Build the agent-hooks middleware bundle from a host declaration file. - - The file is read once, here, through ``agent_hooks.HostDeclaration.from_path`` (a regular - UTF-8 file of at most 1 MiB; an unreadable file is - ``agent_hooks.DeclarationError`` with class ``unreadable``). Everything else is as - :func:`create_agent_hooks_middleware_from_declaration`. - - Args: - path: The path of the declaration document. - - Keyword Args: - interceptors: See :func:`create_agent_hooks_middleware_from_declaration`. - registry: See :func:`create_agent_hooks_middleware_from_declaration`. - record_sink: Optional callable receiving every interception record. - - Returns: - The middleware bundle to pass (as one element) to ``Agent(middleware=...)``. - """ - _require_declaration_sdk() - from agent_hooks import HostDeclaration - - return _bundle_from_declaration( - HostDeclaration.from_path(path), interceptors=interceptors, registry=registry, record_sink=record_sink - ) - - -@experimental(feature_id=ExperimentalFeature.AGENT_HOOKS) -def create_agent_hooks_middleware_from_declaration_json( - json_str: str, - *, - interceptors: Mapping[str, Interceptor] | None = None, - registry: HostRegistry | None = None, - record_sink: Callable[[InterceptionRecord], None] | None = None, -) -> MiddlewareBundle: - """Build the agent-hooks middleware bundle from host declaration JSON text. - - The text goes through ``agent_hooks.HostDeclaration.from_json``; everything else is as - :func:`create_agent_hooks_middleware_from_declaration`. - - Args: - json_str: The declaration document as JSON text. - - Keyword Args: - interceptors: See :func:`create_agent_hooks_middleware_from_declaration`. - registry: See :func:`create_agent_hooks_middleware_from_declaration`. - record_sink: Optional callable receiving every interception record. - - Returns: - The middleware bundle to pass (as one element) to ``Agent(middleware=...)``. - """ - _require_declaration_sdk() - from agent_hooks import HostDeclaration - - return _bundle_from_declaration( - HostDeclaration.from_json(json_str), interceptors=interceptors, registry=registry, record_sink=record_sink + # A registry for this call only: it is never handed back, so a refused document or a raising + # callback cannot poison a later call, and nothing caller-owned is mutated. + registry = _host_registry() + if interceptors is not None: + registry.kind(_DECLARATION_KIND, _interceptor_kind_resolver(dict(interceptors))) + if configure_registry is not None: + configure_registry(registry) + # Load once here so a refused document (every class of spec section 7.7.6, kind resolvers + # included) raises ``agent_hooks.DeclarationError`` at construction, before any run. + resolved = InterceptionEmitter.from_declaration(document, registry).declaration + if resolved is None or not resolved.bindings: + # The spec allows an empty-deny host that is written down; for an agent it means every + # run is denied at ``input``, so refuse it here like ``create_agent_hooks_middleware``. + raise ValueError( + "create_agent_hooks_middleware_from_declaration requires at least one binding in the " + "declaration (an emitter with zero interceptors fails closed on every emission)." + ) + config = _AgentHooksConfig( + interceptors=(), + resolver=None, + mode=None, + composition=None, + identity_provider=None, + timeout=None, + record_sink=record_sink, + emitter=None, + builder=None, + declaration=document, + registry=registry, ) + return _build_bundle(config) def _build_bundle(config: _AgentHooksConfig) -> MiddlewareBundle: diff --git a/python/packages/core/tests/core/test_agent_hooks.py b/python/packages/core/tests/core/test_agent_hooks.py index 2ef60214396..84e2bd26c77 100644 --- a/python/packages/core/tests/core/test_agent_hooks.py +++ b/python/packages/core/tests/core/test_agent_hooks.py @@ -6,7 +6,6 @@ import json from collections import UserDict from collections.abc import AsyncIterable, Awaitable, Callable, Mapping -from dataclasses import replace from pathlib import Path from types import MappingProxyType from typing import Any, cast @@ -34,11 +33,8 @@ MiddlewareFailure, MiddlewareTermination, ResponseStream, - create_agent_hooks_host_registry, create_agent_hooks_middleware, create_agent_hooks_middleware_from_declaration, - create_agent_hooks_middleware_from_declaration_json, - create_agent_hooks_middleware_from_declaration_path, create_agent_hooks_middleware_from_emitter, tool, ) @@ -61,7 +57,7 @@ try: # The host-declaration loader ships in agent-hooks-sdk 0.1.0b2+; older SDKs lack these names. - from agent_hooks import BindingContext, DeclarationError, DeclarationErrorClass, HostDeclaration + from agent_hooks import BindingContext, DeclarationError, DeclarationErrorClass, HostDeclaration, HostRegistry DECLARATION_AVAILABLE = True except ImportError: # pragma: no cover - exercised on envs with an older SDK @@ -3262,7 +3258,7 @@ async def test_declaration_value_runs_one_enforced_session_per_run(chat_client_b @requires_declaration_sdk -async def test_declaration_path_json_and_value_build_the_same_enforcement( +async def test_declaration_all_input_forms_build_the_same_enforcement( chat_client_base: MockBaseChatClient, tmp_path: Path ) -> None: document = declaration_document(framework_binding("allow"), framework_binding("egress", at=["output"])) @@ -3270,24 +3266,30 @@ async def test_declaration_path_json_and_value_build_the_same_enforcement( path.write_text(json.dumps(document), encoding="utf-8") summaries: dict[str, list[Any]] = {} - factories: list[tuple[str, Callable[..., MiddlewareBundle]]] = [ - ("value", lambda **kw: create_agent_hooks_middleware_from_declaration(document, **kw)), - ("json", lambda **kw: create_agent_hooks_middleware_from_declaration_json(json.dumps(document), **kw)), - ("path", lambda **kw: create_agent_hooks_middleware_from_declaration_path(path, **kw)), + declarations: list[tuple[str, Any]] = [ + ("value", document), + # JSON text is not a factory input; the SDK's text path turns it into a HostDeclaration. + ("json", HostDeclaration.from_json(json.dumps(document))), + ("str_path", str(path)), + ("pathlike", path), ] - for label, factory in factories: + for label, declaration in declarations: records: list[InterceptionRecord] = [] agent = Agent( client=chat_client_base, middleware=[ - factory(interceptors={"allow": AllowGuard(), "egress": AllowGuard()}, record_sink=records.append) + create_agent_hooks_middleware_from_declaration( + declaration, + interceptors={"allow": AllowGuard(), "egress": AllowGuard()}, + record_sink=records.append, + ) ], ) response = await agent.run("hello") assert response.text == "test response - hello" summaries[label] = record_summary(records) - assert summaries["value"] == summaries["json"] == summaries["path"] + assert summaries["value"] == summaries["json"] == summaries["str_path"] == summaries["pathlike"] assert summaries["value"][0][1] == DECLARATION_VERSION @@ -3343,6 +3345,14 @@ async def test_declaration_runs_are_separate_sessions(chat_client_base: MockBase "surface_unsupported", id="posture-defaults-to-continue", ), + pytest.param( + # This middleware buffers a streaming run until the output point is judged, and the + # registry always carries that surface, so a document that wants unbuffered streaming + # is refused however the factory is called. + {**declaration_document(), "surface": {"buffered_output": False, "exposure_bound": "one-chunk"}}, + "surface_unsupported", + id="streaming-unbuffered", + ), pytest.param( declaration_document({"id": "guard", "kind": "acme.guard"}), "kind_unknown", @@ -3419,21 +3429,35 @@ def test_declaration_interceptor_kind_refuses_bad_config(config: Any) -> None: @requires_declaration_sdk -async def test_declaration_with_zero_bindings_fails_closed(chat_client_base: MockBaseChatClient) -> None: +def test_declaration_with_zero_bindings_is_rejected_at_construction() -> None: + # The spec allows an empty-deny host that is written down; for an agent it would deny every + # run at ``input``, so the factory refuses it like ``create_agent_hooks_middleware`` does. + with pytest.raises(ValueError, match="at least one binding"): + create_agent_hooks_middleware_from_declaration(declaration_document()) + + +@requires_declaration_sdk +async def test_declaration_lambda_callback_registers_a_kind(chat_client_base: MockBaseChatClient) -> None: + # ``configure_registry`` ignores the callback's return value, so a lambda over the chaining + # registry methods is enough. records: list[InterceptionRecord] = [] agent = Agent( client=chat_client_base, - middleware=[create_agent_hooks_middleware_from_declaration(declaration_document(), record_sink=records.append)], + middleware=[ + create_agent_hooks_middleware_from_declaration( + declaration_document({"id": "allow", "kind": "acme.allow"}), + configure_registry=lambda registry: registry.kind("acme.allow", lambda _c, _b: AllowGuard()), + record_sink=records.append, + ) + ], ) - with pytest.raises(InterceptionBlocked): - await agent.run("hello") + await agent.run("hello") - assert chat_client_base.call_count == 0 - assert points(records) == ["agent_startup", "agent_shutdown"] - assert records[0].verdict.decision.value == "deny" - assert records[0].verdict.reason == "host_error:no_interceptor" - assert records[0].interceptors_registered == 0 + assert chat_client_base.call_count == 1 # the bound interceptor allowed the run through + assert "pre_model_call" in points(records) + assert all(record.verdict.decision.value == "allow" for record in records) + assert all(record.interceptors_registered == 1 for record in records) @requires_declaration_sdk @@ -3499,9 +3523,11 @@ def resolve(self, request: ApprovalRequest) -> ApprovalResolution: verdict=ALLOW, ) + def configure(registry: HostRegistry) -> None: + registry.approval_resolver("approve-all", ApproveAll()) + records: list[InterceptionRecord] = [] guard = PointGuard("pre_tool_call", Verdict.escalate(reason="needs_approval")) - registry = create_agent_hooks_host_registry().approval_resolver("approve-all", ApproveAll()) chat_client_base.run_responses = [tool_call_response(), final_response()] agent = Agent( client=chat_client_base, @@ -3510,7 +3536,7 @@ def resolve(self, request: ApprovalRequest) -> ApprovalResolution: create_agent_hooks_middleware_from_declaration( declaration_document(framework_binding("guard"), approval={"resolver": "approve-all"}), interceptors={"guard": guard}, - registry=registry, + configure_registry=configure, record_sink=records.append, ) ], @@ -3525,6 +3551,55 @@ def resolve(self, request: ApprovalRequest) -> ApprovalResolution: assert lifted[0].interception_point.value == "pre_tool_call" +@requires_declaration_sdk +async def test_declaration_approval_redactor_is_resolved_by_reference(chat_client_base: MockBaseChatClient) -> None: + from agent_hooks import ApprovalOutcome, ApprovalRequest, ApprovalResolution + + presented: list[dict[str, Any]] = [] + + class ApproveAll: + def resolve(self, request: ApprovalRequest) -> ApprovalResolution: + presented.append(dict(request.context)) + return ApprovalResolution( + outcome=ApprovalOutcome.APPROVE, + context_identity=request.context_identity, + verdict=ALLOW, + ) + + def redact(context: dict[str, Any]) -> dict[str, Any]: + # The resolver sees the tool call without its arguments (spec section 9). + return {**context, "tool_call": {**context["tool_call"], "args": {}}} + + def configure(registry: HostRegistry) -> None: + registry.approval_resolver("approve-all", ApproveAll()).approval_redactor("no-args", redact) + + records: list[InterceptionRecord] = [] + guard = PointGuard("pre_tool_call", Verdict.escalate(reason="needs_approval")) + chat_client_base.run_responses = [tool_call_response(), final_response()] + agent = Agent( + client=chat_client_base, + tools=[weather_tool], + middleware=[ + create_agent_hooks_middleware_from_declaration( + declaration_document( + framework_binding("guard"), approval={"resolver": "approve-all", "redactor": "no-args"} + ), + interceptors={"guard": guard}, + configure_registry=configure, + record_sink=records.append, + ) + ], + ) + + response = await agent.run("get the weather") + + assert response.text == "Final response" + assert weather_tool_calls == ["Seattle"] # approved, and the tool ran with its real arguments + assert len(presented) == 1 + assert presented[0]["tool_call"]["args"] == {} + assert [record.resolved_by for record in records].count("approval") == 1 + + @requires_declaration_sdk async def test_declaration_host_registered_kind_receives_the_binding_context( chat_client_base: MockBaseChatClient, @@ -3536,7 +3611,9 @@ def acme_guard(config: Any, binding: BindingContext) -> Any: seen.append(binding) return PointGuard(config["point"], Verdict.deny(reason=config["reason"])) - registry = create_agent_hooks_host_registry().kind("acme.guard", acme_guard) + def configure(registry: HostRegistry) -> None: + registry.kind("acme.guard", acme_guard) + document = declaration_document({ "id": "egress", "kind": "acme.guard", @@ -3546,7 +3623,9 @@ def acme_guard(config: Any, binding: BindingContext) -> Any: agent = Agent( client=chat_client_base, middleware=[ - create_agent_hooks_middleware_from_declaration(document, registry=registry, record_sink=records.append) + create_agent_hooks_middleware_from_declaration( + document, configure_registry=configure, record_sink=records.append + ) ], ) @@ -3565,77 +3644,140 @@ def acme_guard(config: Any, binding: BindingContext) -> Any: @requires_declaration_sdk -def test_declaration_registry_already_holding_the_framework_kind_is_rejected() -> None: - registry = create_agent_hooks_host_registry().kind("agent_framework.interceptor", lambda _c, _b: AllowGuard()) +async def test_declaration_configure_registry_kind_and_identity_provider_are_honoured( + chat_client_base: MockBaseChatClient, +) -> None: + from agent_framework._agent_hooks import _host_surface - with pytest.raises(ValueError, match="registered twice"): - create_agent_hooks_middleware_from_declaration( - declaration_document(framework_binding("allow")), interceptors={"allow": AllowGuard()}, registry=registry - ) + # The kind and the identity provider registered through the callback are available to the + # document, at construction and on the per-run reload; the registry the callback gets is + # built for this call, over the surface this middleware implements. + records: list[InterceptionRecord] = [] + identities: list[str] = [] + registries: list[HostRegistry] = [] + def acme_identity(_context: Any) -> str: + identities.append("fixed-identity") + return identities[-1] -@requires_declaration_sdk -def test_declaration_registry_over_another_posture_is_refused() -> None: - from agent_hooks import HostRegistry + def configure(registry: HostRegistry) -> None: + registries.append(registry) + registry.kind("acme.allow", lambda _c, _b: AllowGuard()).identity_provider("acme-id", acme_identity) - # The SDK validates a document against the registry's own surface. This registry says - # ``continue``, so the SDK alone accepts a document that omits the posture; the middleware - # terminates the run on a tool-seam host error and cannot honour that document. - registry = HostRegistry(replace(create_agent_hooks_host_registry().surface, tool_seam_host_error="continue")) - document = {"declaration": DECLARATION_VERSION, "bindings": []} - assert registry.resolve(HostDeclaration.from_value(document)).declaration == DECLARATION_VERSION + document = declaration_document({"id": "allow", "kind": "acme.allow"}, identity_provider="acme-id") + agent = Agent( + client=chat_client_base, + middleware=[ + create_agent_hooks_middleware_from_declaration( + document, configure_registry=configure, record_sink=records.append + ) + ], + ) - with pytest.raises(ValueError, match="tool_seam_host_error='continue'") as info: - create_agent_hooks_middleware_from_declaration(document, registry=registry) + response = await agent.run("hello") - # A programming error of the host, not a refusal of the document. - assert not isinstance(info.value, DeclarationError) - assert "create_agent_hooks_host_registry" in str(info.value) + assert response.text == "test response - hello" + assert points(records) == [ + "agent_startup", + "input", + "pre_model_call", + "post_model_call", + "output", + "agent_shutdown", + ] + assert all(record.verdicts[0].name == "allow" for record in records) + assert {record.identity_provider for record in records} == {"acme-id"} + assert {record.input_identity for record in records} == {"fixed-identity"} + assert len(identities) >= len(records) + assert len(registries) == 1 + assert registries[0].surface.to_wire() == _host_surface().to_wire() + assert registries[0].names()["kinds"] == ["acme.allow"] @requires_declaration_sdk -def test_declaration_registry_streaming_unbuffered_is_refused() -> None: - from agent_hooks import HostRegistry - - # This middleware buffers a streaming run until the output point is judged. A registry that - # advertises unbuffered streaming lets the SDK accept ``buffered_output: false``, which the - # middleware would then not honour. - surface = create_agent_hooks_host_registry().surface.with_capabilities(["incremental_output"]) - registry = HostRegistry(surface.with_exposure_bound("one-chunk")) - document = {**declaration_document(), "surface": {"buffered_output": False, "exposure_bound": "one-chunk"}} - assert registry.resolve(HostDeclaration.from_value(document)).declaration == DECLARATION_VERSION +async def test_declaration_callback_registering_the_framework_kind_with_interceptors_is_rejected( + chat_client_base: MockBaseChatClient, +) -> None: + # The factory registers ``agent_framework.interceptor`` before the callback runs, so a + # callback that registers it too hits the SDK's own "registered twice" check. Nothing is + # cached: the same ``interceptors`` without the callback, and the same callback without + # ``interceptors``, both build, and the former runs. + records: list[InterceptionRecord] = [] + interceptors = {"allow": AllowGuard()} + document = declaration_document(framework_binding("allow")) - with pytest.raises(ValueError, match="streams_unbuffered=True") as info: - create_agent_hooks_middleware_from_declaration(document, registry=registry) + def configure(registry: HostRegistry) -> None: + registry.kind("agent_framework.interceptor", lambda _c, _b: AllowGuard()) + with pytest.raises(ValueError, match="registered twice") as info: + create_agent_hooks_middleware_from_declaration( + document, interceptors=interceptors, configure_registry=configure + ) + # A programming error of the host, not a refusal of the document. assert not isinstance(info.value, DeclarationError) + agent = Agent( + client=chat_client_base, + middleware=[ + create_agent_hooks_middleware_from_declaration( + document, interceptors=interceptors, record_sink=records.append + ) + ], + ) + response = await agent.run("hello") + + assert response.text == "test response - hello" + assert points(records) == [ + "agent_startup", + "input", + "pre_model_call", + "post_model_call", + "output", + "agent_shutdown", + ] + assert isinstance( + create_agent_hooks_middleware_from_declaration(document, configure_registry=configure), MiddlewareBundle + ) + @requires_declaration_sdk -async def test_declaration_compatible_custom_registry_keeps_its_registered_names( - chat_client_base: MockBaseChatClient, +@pytest.mark.parametrize("with_interceptors", [False, True], ids=["custom-kind-only", "with-interceptors"]) +async def test_declaration_configure_registry_is_reusable_after_a_refused_document( + chat_client_base: MockBaseChatClient, with_interceptors: bool ) -> None: - from agent_hooks import HostRegistry + # Every call builds its own registry, so a refused document leaves nothing behind: the same + # callback (and the same ``interceptors`` mapping) build a working bundle on the next call. + registries: list[HostRegistry] = [] + kind_guards: list[AllowGuard] = [] + guard = AllowGuard() + interceptors = {"allow": guard} if with_interceptors else None - # A registry built by hand over the framework's surface passes the surface check, and the - # kind and identity provider registered on it stay available to the document. - records: list[InterceptionRecord] = [] - identities: list[str] = [] + def acme_allow(_config: Any, _binding: BindingContext) -> AllowGuard: + kind_guards.append(AllowGuard()) + return kind_guards[-1] - def acme_identity(_context: Any) -> str: - identities.append("fixed-identity") - return identities[-1] + def configure(registry: HostRegistry) -> None: + registries.append(registry) + registry.kind("acme.allow", acme_allow) - registry = ( - HostRegistry(create_agent_hooks_host_registry().surface) - .kind("acme.allow", lambda _c, _b: AllowGuard()) - .identity_provider("acme-id", acme_identity) - ) - document = declaration_document({"id": "allow", "kind": "acme.allow"}, identity_provider="acme-id") + refused = declaration_document({"id": "guard", "kind": "acme.unknown"}) + with pytest.raises(DeclarationError) as info: + create_agent_hooks_middleware_from_declaration(refused, interceptors=interceptors, configure_registry=configure) + assert info.value.error_class is DeclarationErrorClass.KIND_UNKNOWN + + bindings: list[dict[str, Any]] = [{"id": "acme", "kind": "acme.allow"}] + if with_interceptors: + bindings.append(framework_binding("allow")) + records: list[InterceptionRecord] = [] agent = Agent( client=chat_client_base, middleware=[ - create_agent_hooks_middleware_from_declaration(document, registry=registry, record_sink=records.append) + create_agent_hooks_middleware_from_declaration( + declaration_document(*bindings), + interceptors=interceptors, + configure_registry=configure, + record_sink=records.append, + ) ], ) @@ -3650,10 +3792,117 @@ def acme_identity(_context: Any) -> str: "output", "agent_shutdown", ] - assert all(record.verdicts[0].name == "allow" for record in records) - assert {record.identity_provider for record in records} == {"acme-id"} - assert {record.input_identity for record in records} == {"fixed-identity"} - assert len(identities) >= len(records) + expected_names = ["acme", "allow"] if with_interceptors else ["acme"] + assert all([verdict.name for verdict in record.verdicts] == expected_names for record in records) + # The run went through the bound interceptors: the kind's (resolved again for the run's + # session) and, when supplied, the shared instance behind ``agent_framework.interceptor``. + assert [ctx["input"]["content"] for ctx in kind_guards[-1].contexts_for("input")] == ["hello"] + if with_interceptors: + assert [ctx["input"]["content"] for ctx in guard.contexts_for("input")] == ["hello"] + # The refused call's registry was dropped with that call; the bundle has its own. + assert len(registries) == 2 + assert registries[0] is not registries[1] + + +@requires_declaration_sdk +async def test_declaration_configure_registry_is_reusable_after_the_callback_raised( + chat_client_base: MockBaseChatClient, +) -> None: + # A raising callback propagates as is (not wrapped, not a document refusal), and nothing is + # cached: the next call starts from a fresh registry and builds. + calls = 0 + registries: list[HostRegistry] = [] + guard = AllowGuard() + interceptors = {"allow": guard} + document = declaration_document({"id": "acme", "kind": "acme.allow"}, framework_binding("allow")) + + def configure(registry: HostRegistry) -> None: + nonlocal calls + calls += 1 + registries.append(registry) + if calls == 1: + raise RuntimeError("host setup failed") + registry.kind("acme.allow", lambda _c, _b: AllowGuard()) + + with pytest.raises(RuntimeError, match="host setup failed"): + create_agent_hooks_middleware_from_declaration( + document, interceptors=interceptors, configure_registry=configure + ) + + records: list[InterceptionRecord] = [] + agent = Agent( + client=chat_client_base, + middleware=[ + create_agent_hooks_middleware_from_declaration( + document, interceptors=interceptors, configure_registry=configure, record_sink=records.append + ) + ], + ) + + response = await agent.run("hello") + + assert response.text == "test response - hello" + assert all([verdict.name for verdict in record.verdicts] == ["acme", "allow"] for record in records) + assert [ctx["input"]["content"] for ctx in guard.contexts_for("input")] == ["hello"] + assert calls == 2 + assert registries[0] is not registries[1] + + +@requires_declaration_sdk +async def test_declaration_two_factories_share_a_callback_and_interceptors( + chat_client_base: MockBaseChatClient, +) -> None: + # One callback and one ``interceptors`` mapping serve two factory calls: each call gets its + # own registry, both bundles build and run, each run's records carry their own session, and + # the first bundle is unaffected by the second. + registries: list[HostRegistry] = [] + guard = AllowGuard() + interceptors = {"allow": guard} + + def configure(registry: HostRegistry) -> None: + registries.append(registry) + registry.kind("acme.allow", lambda _c, _b: AllowGuard()) + + document = declaration_document({"id": "acme", "kind": "acme.allow"}, framework_binding("allow")) + records_first: list[InterceptionRecord] = [] + records_second: list[InterceptionRecord] = [] + first = Agent( + client=chat_client_base, + name="first", + middleware=[ + create_agent_hooks_middleware_from_declaration( + document, interceptors=interceptors, configure_registry=configure, record_sink=records_first.append + ) + ], + ) + second = Agent( + client=MockBaseChatClient(), + name="second", + middleware=[ + create_agent_hooks_middleware_from_declaration( + document, interceptors=interceptors, configure_registry=configure, record_sink=records_second.append + ) + ], + ) + assert len(registries) == 2 + assert registries[0] is not registries[1] + + assert (await first.run("one")).text == "test response - one" + assert (await second.run("two")).text == "test response - two" + assert (await first.run("three")).text == "test response - three" + + single_run = ["agent_startup", "input", "pre_model_call", "post_model_call", "output", "agent_shutdown"] + assert points(records_first) == single_run * 2 + assert points(records_second) == single_run + sessions_first = {record.session_id for record in records_first} + sessions_second = {record.session_id for record in records_second} + assert len(sessions_first) == 2 + assert len(sessions_second) == 1 + assert sessions_first.isdisjoint(sessions_second) + assert all([verdict.name for verdict in record.verdicts] == ["acme", "allow"] for record in records_first) + assert all([verdict.name for verdict in record.verdicts] == ["acme", "allow"] for record in records_second) + # The shared interceptor instance saw every run of both agents, in order. + assert [ctx["input"]["content"] for ctx in guard.contexts_for("input")] == ["one", "two", "three"] @requires_declaration_sdk @@ -3690,13 +3939,13 @@ async def test_declaration_value_accepts_any_mapping( "value", [ pytest.param(["not", "a", "document"], id="list"), - pytest.param("not a document", id="string"), + pytest.param(42, id="number"), pytest.param(None, id="null"), ], ) def test_declaration_non_mapping_value_gets_the_sdk_refusal(value: Any) -> None: - # Only a top-level mapping is copied; anything else reaches the SDK as given and gets its - # own refusal, at the root pointer. + # Only a top-level mapping is copied; anything else that is not a path reaches the SDK as + # given and gets its own refusal, at the root pointer. with pytest.raises(DeclarationError) as info: create_agent_hooks_middleware_from_declaration(value) @@ -3705,7 +3954,7 @@ def test_declaration_non_mapping_value_gets_the_sdk_refusal(value: Any) -> None: @requires_declaration_sdk -def test_declaration_factories_accept_a_built_host_declaration() -> None: +def test_declaration_factory_accepts_a_built_host_declaration() -> None: from agent_hooks import DeclarationBuilder built: HostDeclaration = ( @@ -3718,11 +3967,21 @@ def test_declaration_factories_accept_a_built_host_declaration() -> None: @requires_declaration_sdk -def test_declaration_path_refuses_an_unreadable_file(tmp_path: Path) -> None: +@pytest.mark.parametrize( + "declaration", + [ + pytest.param(lambda tmp_path: str(tmp_path / "missing.json"), id="str"), + pytest.param(lambda tmp_path: tmp_path / "missing.json", id="pathlike"), + # A string is a path, never JSON text: the text is looked up as a file and is not there. + pytest.param(lambda tmp_path: json.dumps(declaration_document(framework_binding("allow"))), id="json-text"), + ], +) +def test_declaration_unreadable_path_is_refused(declaration: Callable[[Path], Any], tmp_path: Path) -> None: with pytest.raises(DeclarationError) as info: - create_agent_hooks_middleware_from_declaration_path(tmp_path / "missing.json") + create_agent_hooks_middleware_from_declaration(declaration(tmp_path), interceptors={"allow": AllowGuard()}) assert info.value.error_class is DeclarationErrorClass.UNREADABLE + assert info.value.findings[0].pointer == "" @requires_declaration_sdk @@ -3763,10 +4022,13 @@ async def test_declaration_streaming_run_is_buffered(chat_client_base: MockBaseC @requires_declaration_sdk async def test_declaration_built_emitter_works_with_a_host_owned_session(chat_client_base: MockBaseChatClient) -> None: + from agent_framework._agent_hooks import _host_surface + # No new factory needed for this: a sealed, declaration-built emitter is a valid host-owned - # session for the existing ``create_agent_hooks_middleware_from_emitter`` path. + # session for the existing ``create_agent_hooks_middleware_from_emitter`` path. The host + # builds its registry over the surface this middleware implements. records: list[InterceptionRecord] = [] - registry = create_agent_hooks_host_registry().kind("acme.allow", lambda _c, _b: AllowGuard()) + registry = HostRegistry(_host_surface()).kind("acme.allow", lambda _c, _b: AllowGuard()) emitter = InterceptionEmitter.from_declaration_value( declaration_document({"id": "allow", "kind": "acme.allow"}), registry )