diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 8c5b083d74..e61cf8e23d 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -82,12 +82,14 @@ 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` 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 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 + 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 5a0b6800ab..8132f659c9 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -42,7 +42,11 @@ ) _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_middleware", + "create_agent_hooks_middleware_from_declaration", + "create_agent_hooks_middleware_from_emitter", + ), "._agents": ("Agent", "BaseAgent", "RawAgent", "SupportsAgentRun"), "._clients": ( "BaseChatClient", @@ -682,6 +686,7 @@ "background_tasks_running_message", "chat_middleware", "create_agent_hooks_middleware", + "create_agent_hooks_middleware_from_declaration", "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 c5d5b0a250..9a7e0fdfad 100644 --- a/python/packages/core/agent_framework/__init__.pyi +++ b/python/packages/core/agent_framework/__init__.pyi @@ -4,7 +4,11 @@ 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_middleware, + create_agent_hooks_middleware_from_declaration, + create_agent_hooks_middleware_from_emitter, +) from ._agents import Agent, BaseAgent, RawAgent, SupportsAgentRun from ._clients import ( BaseChatClient, @@ -637,6 +641,7 @@ __all__ = [ "background_tasks_running_message", "chat_middleware", "create_agent_hooks_middleware", + "create_agent_hooks_middleware_from_declaration", "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 830ddf2faa..c3e122f2d5 100644 --- a/python/packages/core/agent_framework/_agent_hooks.py +++ b/python/packages/core/agent_framework/_agent_hooks.py @@ -10,14 +10,17 @@ - ``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 -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 per run), :func:`create_agent_hooks_middleware_from_emitter` (host-owned +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"``): @@ -101,6 +104,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 +146,13 @@ from agent_hooks import ( AgentContextBuilder, ApprovalResolver, + BindingContext, CompositionConfig, EmitOutcome, EnforcementMode, + HostDeclaration, + HostRegistry, + HostSurface, IdentityProvider, InterceptionBlocked, InterceptionEmitter, @@ -164,6 +172,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 +235,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 +266,9 @@ class _AgentHooksConfig: record_sink: Callable[[InterceptionRecord], None] | None emitter: InterceptionEmitter | None builder: AgentContextBuilder | None + # Set by the host-declaration factory: every run loads a fresh emitter from this pair. + declaration: HostDeclaration | None = None + registry: HostRegistry | None = None @dataclass @@ -1036,17 +1078,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 +1746,212 @@ 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: + """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 _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 + + +@experimental(feature_id=ExperimentalFeature.AGENT_HOOKS) +def create_agent_hooks_middleware_from_declaration( + declaration: HostDeclaration | Mapping[str, Any] | str | os.PathLike[str], + *, + interceptors: Mapping[str, Interceptor] | 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. + + 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 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 + 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 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 + 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`. + + 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, 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. + 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: + 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 (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 + + 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()}) + ], + ) + + 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, 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 + # 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) + + # 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: 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 e87193c8a5..84e2bd26c7 100644 --- a/python/packages/core/tests/core/test_agent_hooks.py +++ b/python/packages/core/tests/core/test_agent_hooks.py @@ -3,7 +3,11 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterable, Awaitable, Callable +import json +from collections import UserDict +from collections.abc import AsyncIterable, Awaitable, Callable, Mapping +from pathlib import Path +from types import MappingProxyType from typing import Any, cast import pytest @@ -30,6 +34,7 @@ MiddlewareTermination, ResponseStream, create_agent_hooks_middleware, + create_agent_hooks_middleware_from_declaration, create_agent_hooks_middleware_from_emitter, tool, ) @@ -50,9 +55,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, HostRegistry + + 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 +154,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 +200,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 +211,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 +3220,830 @@ 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_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"])) + path = tmp_path / "declaration.json" + path.write_text(json.dumps(document), encoding="utf-8") + + summaries: dict[str, list[Any]] = {} + 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, declaration in declarations: + records: list[InterceptionRecord] = [] + agent = Agent( + client=chat_client_base, + middleware=[ + 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["str_path"] == summaries["pathlike"] + 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( + # 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", + 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 +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({"id": "allow", "kind": "acme.allow"}), + configure_registry=lambda registry: registry.kind("acme.allow", lambda _c, _b: AllowGuard()), + record_sink=records.append, + ) + ], + ) + + await agent.run("hello") + + 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 +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, + ) + + def configure(registry: HostRegistry) -> None: + registry.approval_resolver("approve-all", ApproveAll()) + + 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"}), + 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"] # 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_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, +) -> 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"])) + + def configure(registry: HostRegistry) -> None: + 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, configure_registry=configure, 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 +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 + + # 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] + + def configure(registry: HostRegistry) -> None: + registries.append(registry) + registry.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, configure_registry=configure, 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) + assert len(registries) == 1 + assert registries[0].surface.to_wire() == _host_surface().to_wire() + assert registries[0].names()["kinds"] == ["acme.allow"] + + +@requires_declaration_sdk +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")) + + 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 +@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: + # 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 + + def acme_allow(_config: Any, _binding: BindingContext) -> AllowGuard: + kind_guards.append(AllowGuard()) + return kind_guards[-1] + + def configure(registry: HostRegistry) -> None: + registries.append(registry) + registry.kind("acme.allow", acme_allow) + + 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( + declaration_document(*bindings), + interceptors=interceptors, + configure_registry=configure, + 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", + ] + 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 +@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(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 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) + + assert info.value.error_class is DeclarationErrorClass.MALFORMED + assert info.value.findings[0].pointer == "" + + +@requires_declaration_sdk +def test_declaration_factory_accepts_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 +@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(declaration(tmp_path), interceptors={"allow": AllowGuard()}) + + assert info.value.error_class is DeclarationErrorClass.UNREADABLE + assert info.value.findings[0].pointer == "" + + +@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: + 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. The host + # builds its registry over the surface this middleware implements. + records: list[InterceptionRecord] = [] + registry = HostRegistry(_host_surface()).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 a318c267a8..1e67845c07 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -39,7 +39,7 @@ dev = [ "azure-monitor-opentelemetry>=1.8.10,<2", "mcp", # 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 6571372d26..14390aa299 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" }, { name = "opentelemetry-sdk" }, @@ -1086,15 +1086,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]]