From e11b9c0dc3f4fe0b773fe80e04776723541832e0 Mon Sep 17 00:00:00 2001 From: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:27:48 +0000 Subject: [PATCH 1/5] docs: map AGENT-HOOKS-0.1 interception points onto middleware seams Survey of how the eight interception points of the agent-hooks control contract (github.com/responsibleai/agent-hooks) land on the Python middleware pipeline: agent/chat/function middleware cover six points cleanly; the run brackets are synthesized with a session-per-run scope; streaming post-action points and session-scoped brackets are the two seams that would need upstream support. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> --- python/packages/agent-hooks/MAPPING.md | 59 ++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 python/packages/agent-hooks/MAPPING.md diff --git a/python/packages/agent-hooks/MAPPING.md b/python/packages/agent-hooks/MAPPING.md new file mode 100644 index 00000000000..2b04fe3f361 --- /dev/null +++ b/python/packages/agent-hooks/MAPPING.md @@ -0,0 +1,59 @@ +# Mapping AGENT-HOOKS-0.1 onto Agent Framework middleware + +[AGENT-HOOKS-0.1](https://github.com/responsibleai/agent-hooks) defines +eight interception points a host emits around the agent loop, a +three-verdict control contract (`allow` / `deny`, optionally liftable +by an approval seam / `transform`), and fail-closed host obligations. +This package implements that contract on Agent Framework's Python +middleware pipeline. + +## Seam mapping + +| Interception point | Agent Framework seam | Fit | +| --- | --- | --- | +| `agent_startup` | `AgentMiddleware.process`, before `call_next` | Synthesized: emitted at run start; the run is the session (below) | +| `input` | `AgentMiddleware.process`, before `call_next` (`context.messages`) | Clean | +| `pre_model_call` | `ChatMiddleware.process`, before `call_next` (`context.messages`, `context.options`) | Clean | +| `post_model_call` | `ChatMiddleware.process`, after `call_next` (`context.result`) | Clean (non-streaming) | +| `pre_tool_call` | `FunctionMiddleware.process`, before `call_next` (`context.function`, `context.arguments`) | Clean | +| `post_tool_call` | `FunctionMiddleware.process`, after `call_next` (`context.result`) | Clean | +| `output` | `AgentMiddleware.process`, after `call_next` (`context.result`) | Clean (non-streaming) | +| `agent_shutdown` | `AgentMiddleware.process`, `finally` | Synthesized: emitted at run end with `completed` / `error` | + +**Session scope.** Agent Framework middleware wraps *invocations*, not +agent lifecycle: there is no construction/disposal seam. This adapter +therefore scopes one agent-hooks session to one agent run — +`agent_startup` and `agent_shutdown` bracket the run, and `session.id` +is a per-run identifier. Multi-turn state above the run (an +`AgentSession`) has no middleware seam today; a session-scoped bracket +would need a small upstream seam (agent-level `on_session_open/close` +or middleware around `AgentSession`). + +**Control semantics.** A block verdict maps to +`MiddlewareTermination`, the framework's documented early-termination +mechanism; the deny reason travels in the exception message and the +interception record. For post-action points (`post_model_call`, +`post_tool_call`, `output`) the adapter clears `context.result` before +terminating, matching the spec's discard-the-result obligation. +Transforms write back through the context (`context.arguments` for +tool calls), so the framework executes exactly the value the +interceptors approved. + +**Fail-closed.** Errors inside the emitter, an interceptor, or this +adapter's own marshalling terminate the run; they never fall through +to execution. This is the inverse of observe-only callback surfaces +that log and continue. + +## Known gaps (documented, not hidden) + +1. **Streaming runs.** For `context.stream == True`, `output` and + `post_model_call` content is not available until the stream is + consumed. The adapter enforces all pre-action points on streaming + runs but does not currently buffer streams to enforce post-action + points; a finalizer-hook integration (`stream_result_hooks`) is the + natural follow-up. The spec's `buffered_output: false` declaration + covers this honestly in a conformance claim. +2. **Session-scoped brackets** (above). +3. **`post_model_call` tool-call extraction** is best-effort across + client result shapes; unrecognized shapes degrade to an empty + `tool_calls` list rather than failing the run. From 4854f0acdb863a0ed904852a6d0dc0c7967cfe20 Mon Sep 17 00:00:00 2001 From: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:27:48 +0000 Subject: [PATCH 2/5] feat(python): agent-hooks control-contract middleware package agent-framework-agent-hooks implements AGENT-HOOKS-0.1 on the middleware pipeline: the middleware trio emits the eight interception points (session-per-run scope), block verdicts terminate via MiddlewareTermination with the post-action result discarded, transforms write back through the context so execution uses the approved value, and composition/approval/identity/record semantics come from the published agent-hooks-sdk package. Tests cover allow/deny/transform flows and no-op behavior outside a bracketed run. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> --- python/packages/agent-hooks/LICENSE | 21 ++ python/packages/agent-hooks/README.md | 53 +++ .../agent_framework_agent_hooks/__init__.py | 20 ++ .../_middleware.py | 311 ++++++++++++++++++ .../agent_framework_agent_hooks/py.typed | 0 python/packages/agent-hooks/pyproject.toml | 59 ++++ .../tests/test_agent_hooks_middleware.py | 144 ++++++++ python/pyproject.toml | 1 + 8 files changed, 609 insertions(+) create mode 100644 python/packages/agent-hooks/LICENSE create mode 100644 python/packages/agent-hooks/README.md create mode 100644 python/packages/agent-hooks/agent_framework_agent_hooks/__init__.py create mode 100644 python/packages/agent-hooks/agent_framework_agent_hooks/_middleware.py create mode 100644 python/packages/agent-hooks/agent_framework_agent_hooks/py.typed create mode 100644 python/packages/agent-hooks/pyproject.toml create mode 100644 python/packages/agent-hooks/tests/test_agent_hooks_middleware.py diff --git a/python/packages/agent-hooks/LICENSE b/python/packages/agent-hooks/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/agent-hooks/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/agent-hooks/README.md b/python/packages/agent-hooks/README.md new file mode 100644 index 00000000000..0e52eba521e --- /dev/null +++ b/python/packages/agent-hooks/README.md @@ -0,0 +1,53 @@ +# Agent Framework Agent Hooks Middleware + +Implements [AGENT-HOOKS-0.1](https://github.com/responsibleai/agent-hooks), +a framework-neutral control contract for AI agents, on Agent Framework's +middleware pipeline. Any agent-hooks interceptor (policy engine, approval +flow, egress guard, audit pipeline) plugs into an Agent Framework agent +without framework-specific glue, with the contract's fail-closed +semantics: a deny stops the action, a transform rewrites exactly what +executes, and errors terminate rather than fall through. + +## Installation + +```bash +pip install agent-framework-agent-hooks +``` + +## Usage + +```python +from agent_framework import Agent +from agent_framework_agent_hooks import agent_hooks_middleware +from agent_hooks import Decision, Verdict + + +class EgressGuard: + def intercept(self, context): + if context["interception_point"] != "pre_tool_call": + return {"decision": "allow"} + if "confidential" in str(context["target"]): + return {"decision": "deny", "reason": "egress_blocked"} + return {"decision": "allow"} + + +agent = Agent( + client=client, + name="assistant", + middleware=agent_hooks_middleware([EgressGuard()]), +) +``` + +One agent run is one agent-hooks session: `agent_startup` and +`agent_shutdown` bracket the run, `input`/`output` wrap it, and every +model and tool call gets its pre/post interception point. Composition +profiles, the approval seam, identity providers, and interception +records follow the published `agent-hooks-sdk` package; see +[MAPPING.md](MAPPING.md) for the seam mapping and known gaps +(streaming post-action points, session-scoped brackets). + +Trust model: agent-hooks is a cooperative contract, not a security +boundary; the host process and registered interceptors are fully +trusted. See the +[specification](https://github.com/responsibleai/agent-hooks) for the +normative statement. diff --git a/python/packages/agent-hooks/agent_framework_agent_hooks/__init__.py b/python/packages/agent-hooks/agent_framework_agent_hooks/__init__.py new file mode 100644 index 00000000000..60e0a4f9a55 --- /dev/null +++ b/python/packages/agent-hooks/agent_framework_agent_hooks/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft. All rights reserved. +"""AGENT-HOOKS-0.1 control-contract middleware for Agent Framework. + +See https://github.com/responsibleai/agent-hooks for the specification +and MAPPING.md in this package for the seam mapping. +""" + +from ._middleware import ( + AgentHooksAgentMiddleware, + AgentHooksChatMiddleware, + AgentHooksFunctionMiddleware, + agent_hooks_middleware, +) + +__all__ = [ + "AgentHooksAgentMiddleware", + "AgentHooksChatMiddleware", + "AgentHooksFunctionMiddleware", + "agent_hooks_middleware", +] diff --git a/python/packages/agent-hooks/agent_framework_agent_hooks/_middleware.py b/python/packages/agent-hooks/agent_framework_agent_hooks/_middleware.py new file mode 100644 index 00000000000..77037be28f8 --- /dev/null +++ b/python/packages/agent-hooks/agent_framework_agent_hooks/_middleware.py @@ -0,0 +1,311 @@ +# Copyright (c) Microsoft. All rights reserved. +"""AGENT-HOOKS-0.1 middleware for Agent Framework. + +Implements the agent-hooks control contract +(https://github.com/responsibleai/agent-hooks) on the framework's +middleware pipeline. One agent run is one agent-hooks session: +``agent_startup`` and ``agent_shutdown`` bracket the run, agent-level +middleware emits ``input``/``output``, chat middleware emits the model +bracket, and function middleware emits the tool bracket. Block verdicts +terminate the run via ``MiddlewareTermination``; transforms write back +through the middleware context so execution uses exactly the value the +interceptors approved. See MAPPING.md for the seam-by-seam rationale +and known gaps. +""" + +from __future__ import annotations + +import contextlib +import uuid +from collections.abc import Awaitable, Callable, Mapping, Sequence +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any + +from agent_framework import ( + AgentContext, + AgentMiddleware, + ChatContext, + ChatMiddleware, + FunctionInvocationContext, + FunctionMiddleware, + MiddlewareTermination, +) +from agent_hooks import ( + AgentContextBuilder, + ApprovalResolver, + CompositionConfig, + EnforcementMode, + IdentityProvider, + InterceptionBlocked, + InterceptionEmitter, + InterceptionRecord, + Interceptor, +) + +_FRAMEWORK = "agent-framework" + +# Carries the per-run emitter/builder from the agent middleware to the +# inner chat/function pipelines (their contexts do not share metadata). +_RUN: ContextVar["_RunState | None"] = ContextVar("agent_hooks_run", default=None) + + +@dataclass(slots=True) +class _RunState: + emitter: InterceptionEmitter + builder: AgentContextBuilder + + +def _terminate(exc: InterceptionBlocked) -> MiddlewareTermination: + verdict = exc.result.verdict + reason = getattr(verdict, "reason", None) or "blocked" + point = exc.result.interception_point + return MiddlewareTermination(f"agent-hooks {getattr(point, 'value', point)}: {reason}") + + +def _message_to_wire(message: Any) -> dict[str, Any]: + """Best-effort projection of a framework Message to a wire dict.""" + role = getattr(message, "role", None) + role = getattr(role, "value", role) or "user" + text = getattr(message, "text", None) + if text is None: + contents = getattr(message, "contents", None) + text = "" if contents is None else str(contents) + return {"role": str(role), "content": text} + + +def _messages_to_wire(messages: Sequence[Any] | None) -> list[dict[str, Any]]: + return [_message_to_wire(m) for m in (messages or [])] + + +def _result_content(result: Any) -> Any: + for attr in ("text", "content"): + value = getattr(result, attr, None) + if value is not None: + return value + return None if result is None else str(result) + + +def _result_tool_calls(result: Any) -> list[dict[str, Any]]: + """Best-effort extraction of tool calls from a chat result (MAPPING.md gap 3).""" + calls: list[dict[str, Any]] = [] + for message in getattr(result, "messages", None) or []: + for content in getattr(message, "contents", None) or []: + call_id = getattr(content, "call_id", None) + name = getattr(content, "name", None) + if call_id is not None and name is not None: + arguments = getattr(content, "arguments", None) + if not isinstance(arguments, Mapping): + arguments = {} + calls.append({"id": str(call_id), "name": str(name), "args": dict(arguments)}) + return calls + + +def _finish_reason(result: Any) -> str: + reason = getattr(result, "finish_reason", None) + return str(getattr(reason, "value", reason) or "stop") + + +def _arguments_to_dict(arguments: Any) -> dict[str, Any]: + if isinstance(arguments, Mapping): + return dict(arguments) + dump = getattr(arguments, "model_dump", None) + if callable(dump): + return dict(dump()) + return {"value": str(arguments)} + + +class AgentHooksAgentMiddleware(AgentMiddleware): + """Run bracket: ``agent_startup``, ``input``, ``output``, ``agent_shutdown``.""" + + def __init__( + self, + interceptors: Sequence[Interceptor], + *, + resolver: ApprovalResolver | None = None, + mode: EnforcementMode = EnforcementMode.ENFORCE, + composition: CompositionConfig | None = None, + identity_provider: str | IdentityProvider | None = "jcs-sha256", + timeout: float | None = 5.0, + record_sink: Callable[[InterceptionRecord], None] | None = None, + ) -> None: + self._interceptors = list(interceptors) + self._resolver = resolver + self._mode = mode + self._composition = composition + self._identity_provider = identity_provider + self._timeout = timeout + self._record_sink = record_sink + + def _new_emitter(self) -> InterceptionEmitter: + emitter = InterceptionEmitter( + mode=self._mode, + resolver=self._resolver, + timeout=self._timeout, + composition=self._composition, + identity_provider=self._identity_provider, + ) + for interceptor in self._interceptors: + emitter.register(interceptor) + if self._record_sink is not None: + emitter.set_record_sink(self._record_sink) + return emitter + + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + agent = getattr(context, "agent", None) + agent_id = str(getattr(agent, "name", None) or getattr(agent, "id", None) or "agent") + builder = AgentContextBuilder(agent_id=agent_id, framework=_FRAMEWORK, session_id=uuid.uuid4().hex) + emitter = self._new_emitter() + state = _RunState(emitter=emitter, builder=builder) + token = _RUN.set(state) + shutdown_reason = "completed" + try: + tools = [str(getattr(t, "name", t)) for t in (getattr(context, "tools", None) or [])] + try: + await emitter.emit(builder.agent_startup(tools_registered=tools)) + await emitter.emit(builder.input(content=_messages_to_wire(context.messages))) + except InterceptionBlocked as exc: + shutdown_reason = "error" + raise _terminate(exc) from exc + + try: + await call_next() + except BaseException: + shutdown_reason = "error" + raise + + if not context.stream: + try: + await emitter.emit(builder.output(content=_result_content(context.result))) + except InterceptionBlocked as exc: + context.result = None + shutdown_reason = "error" + raise _terminate(exc) from exc + # Streaming: pre-action points are enforced; output content is + # not available until the stream is consumed (MAPPING.md gap 1). + finally: + # Shutdown blocks are record-only per the spec; nothing to halt. + with contextlib.suppress(InterceptionBlocked): + await emitter.emit(builder.agent_shutdown(reason=shutdown_reason)) + _RUN.reset(token) + + +class AgentHooksChatMiddleware(ChatMiddleware): + """Model bracket: ``pre_model_call`` and ``post_model_call``.""" + + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + state = _RUN.get() + if state is None: + await call_next() + return + options = context.options or {} + model_id = str(options.get("model") or type(getattr(context, "client", None)).__name__) + try: + await state.emitter.emit( + state.builder.pre_model_call(model_id=model_id, messages=_messages_to_wire(context.messages)) + ) + except InterceptionBlocked as exc: + raise _terminate(exc) from exc + + await call_next() + + if context.stream: + return # MAPPING.md gap 1: finalized content unavailable here. + result = context.result + try: + await state.emitter.emit( + state.builder.post_model_call( + model_id=model_id, + content=_result_content(result), + tool_calls=_result_tool_calls(result), + finish_reason=_finish_reason(result), + ) + ) + except InterceptionBlocked as exc: + context.result = None + raise _terminate(exc) from exc + + +class AgentHooksFunctionMiddleware(FunctionMiddleware): + """Tool bracket: ``pre_tool_call`` and ``post_tool_call``.""" + + async def process(self, context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]) -> None: + state = _RUN.get() + if state is None: + await call_next() + return + call_id = uuid.uuid4().hex + name = str(getattr(context.function, "name", context.function)) + args = _arguments_to_dict(context.arguments) + try: + outcome = await state.emitter.emit(state.builder.pre_tool_call(call_id=call_id, name=name, args=args)) + except InterceptionBlocked as exc: + raise _terminate(exc) from exc + effective = outcome.target + if isinstance(effective, Mapping) and dict(effective) != args: + # A transform rewrote the arguments: execute the approved value. + args = dict(effective) + context.arguments = args + + await call_next() + + try: + await state.emitter.emit( + state.builder.post_tool_call(call_id=call_id, name=name, args=args, value=context.result) + ) + except InterceptionBlocked as exc: + context.result = None + raise _terminate(exc) from exc + + +def agent_hooks_middleware( + interceptors: Sequence[Interceptor], + *, + resolver: ApprovalResolver | None = None, + mode: EnforcementMode = EnforcementMode.ENFORCE, + composition: CompositionConfig | None = None, + identity_provider: str | IdentityProvider | None = "jcs-sha256", + timeout: float | None = 5.0, + record_sink: Callable[[InterceptionRecord], None] | None = None, +) -> list[AgentMiddleware | ChatMiddleware | FunctionMiddleware]: + """Build the middleware trio that emits AGENT-HOOKS-0.1 interception points. + + Args: + interceptors: agent-hooks interceptors, dispatched per the composition + profile (default ``sequential/first_deny`` with ``on_approval: stop``). + resolver: Optional approval resolver for liftable denies. + mode: ``ENFORCE`` honours verdicts; ``EVALUATE_ONLY`` records them. + composition: Composition profile and knobs; ``None`` uses the default. + identity_provider: ``"jcs-sha256"`` (default), a custom provider, or + ``None`` for identity-unbound records. + timeout: Per-interceptor timeout in seconds (spec RECOMMENDED 5.0). + record_sink: Optional callable receiving every interception record. + + Returns: + Middleware instances to pass to ``Agent(middleware=[...])``. + + Example: + .. code-block:: python + + from agent_framework import Agent + from agent_framework_agent_hooks import agent_hooks_middleware + + agent = Agent( + client=client, + name="assistant", + middleware=agent_hooks_middleware([EgressGuard()]), + ) + """ + return [ + AgentHooksAgentMiddleware( + interceptors, + resolver=resolver, + mode=mode, + composition=composition, + identity_provider=identity_provider, + timeout=timeout, + record_sink=record_sink, + ), + AgentHooksChatMiddleware(), + AgentHooksFunctionMiddleware(), + ] diff --git a/python/packages/agent-hooks/agent_framework_agent_hooks/py.typed b/python/packages/agent-hooks/agent_framework_agent_hooks/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/agent-hooks/pyproject.toml b/python/packages/agent-hooks/pyproject.toml new file mode 100644 index 00000000000..912d6f0eb74 --- /dev/null +++ b/python/packages/agent-hooks/pyproject.toml @@ -0,0 +1,59 @@ +[project] +name = "agent-framework-agent-hooks" +description = "AGENT-HOOKS-0.1 control-contract middleware for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b260725" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.11.0,<2", + "agent-hooks-sdk>=0.1.0a3,<0.2", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.bandit] +targets = ["agent_framework_agent_hooks"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_agent_hooks" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_agent_hooks --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py b/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py new file mode 100644 index 00000000000..7f465c3242d --- /dev/null +++ b/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Tests for the AGENT-HOOKS-0.1 middleware.""" + +from typing import Any + +import pytest +from agent_framework import ( + AgentContext, + ChatContext, + FunctionInvocationContext, + MiddlewareTermination, +) +from agent_framework_agent_hooks import ( + AgentHooksChatMiddleware, + AgentHooksFunctionMiddleware, + agent_hooks_middleware, +) + + +class _AllowAll: + def intercept(self, context: dict[str, Any]) -> dict[str, Any]: + return {"decision": "allow"} + + +class _DenyTool: + def __init__(self, name: str) -> None: + self._name = name + + def intercept(self, context: dict[str, Any]) -> dict[str, Any]: + if context["interception_point"] == "pre_tool_call" and context["tool_call"]["name"] == self._name: + return {"decision": "deny", "reason": "blocked_tool"} + return {"decision": "allow"} + + +class _RedactArg: + def intercept(self, context: dict[str, Any]) -> dict[str, Any]: + if context["interception_point"] == "pre_tool_call": + return { + "decision": "transform", + "transform": {"path": "$target.query", "value": "[redacted]"}, + } + return {"decision": "allow"} + + +class _FakeFunction: + name = "search" + + +def _agent_context(stream: bool = False) -> AgentContext: + context = AgentContext.__new__(AgentContext) + context.agent = type("A", (), {"name": "test-agent"})() + context.messages = [] + context.tools = None + context.stream = stream + context.result = None + return context + + +def _function_context(arguments: dict[str, Any]) -> FunctionInvocationContext: + return FunctionInvocationContext(function=_FakeFunction(), arguments=arguments) + + +async def _run(middlewares: list[Any], fn_ctx: FunctionInvocationContext) -> list[Any]: + """Drive the agent middleware bracket around one function invocation.""" + agent_mw, _, fn_mw = middlewares + records: list[Any] = [] + + async def inner() -> None: + async def call_fn() -> None: + fn_ctx.result = "ok" + + await fn_mw.process(fn_ctx, call_fn) + + agent_ctx = _agent_context() + await agent_mw.process(agent_ctx, inner) + return records + + +async def test_allow_run_completes_and_records() -> None: + records: list[Any] = [] + middlewares = agent_hooks_middleware([_AllowAll()], record_sink=records.append) + fn_ctx = _function_context({"query": "cats"}) + await _run(middlewares, fn_ctx) + assert fn_ctx.result == "ok" + points = [r.interception_point.value for r in records] + assert points == [ + "agent_startup", + "input", + "pre_tool_call", + "post_tool_call", + "output", + "agent_shutdown", + ] + + +async def test_deny_blocks_tool_and_terminates() -> None: + records: list[Any] = [] + middlewares = agent_hooks_middleware([_DenyTool("search")], record_sink=records.append) + fn_ctx = _function_context({"query": "cats"}) + with pytest.raises(MiddlewareTermination, match="blocked_tool"): + await _run(middlewares, fn_ctx) + assert fn_ctx.result is None # tool never executed + points = [r.interception_point.value for r in records] + assert "post_tool_call" not in points # blocked pre point suppresses post + assert points[-1] == "agent_shutdown" # session trail still closed + assert records[-1].verdict.to_wire()["decision"] == "allow" + + +async def test_transform_rewrites_executed_arguments() -> None: + middlewares = agent_hooks_middleware([_RedactArg()]) + fn_ctx = _function_context({"query": "secret data"}) + seen: dict[str, Any] = {} + + async def inner() -> None: + async def call_fn() -> None: + seen.update(dict(fn_ctx.arguments)) + fn_ctx.result = "ok" + + await middlewares[2].process(fn_ctx, call_fn) + + await middlewares[0].process(_agent_context(), inner) + assert seen == {"query": "[redacted]"} # execution saw the approved value + + +async def test_chat_middleware_noop_outside_run() -> None: + called: list[bool] = [] + + async def call_next() -> None: + called.append(True) + + chat_ctx = ChatContext.__new__(ChatContext) + chat_ctx.stream = False + await AgentHooksChatMiddleware().process(chat_ctx, call_next) + assert called == [True] + + +async def test_function_middleware_noop_outside_run() -> None: + fn_ctx = _function_context({"q": 1}) + + async def call_next() -> None: + fn_ctx.result = "ran" + + await AgentHooksFunctionMiddleware().process(fn_ctx, call_next) + assert fn_ctx.result == "ran" diff --git a/python/pyproject.toml b/python/pyproject.toml index 040005cbae0..0760532670f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -74,6 +74,7 @@ members = [ "packages/*" ] agent-framework = { workspace = true } agent-framework-core = { workspace = true } agent-framework-a2a = { workspace = true } +agent-framework-agent-hooks = { workspace = true } agent-framework-ag-ui = { workspace = true } agent-framework-azure-ai-search = { workspace = true } agent-framework-azure-cosmos = { workspace = true } From cd3a81e59698c0f6ccfaae1d7d9eb01e368a1f00 Mon Sep 17 00:00:00 2001 From: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:29:48 +0000 Subject: [PATCH 3/5] feat(python): agent-hooks middleware consumes agent-hooks-sdk 0.1.0a4 Adopts the deny constructor introduced in 0.1.0a4 in place of wire dicts; transform and allow verdicts use the typed constructors. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> --- python/packages/agent-hooks/pyproject.toml | 2 +- .../tests/test_agent_hooks_middleware.py | 21 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/python/packages/agent-hooks/pyproject.toml b/python/packages/agent-hooks/pyproject.toml index 912d6f0eb74..9f2c6af1be5 100644 --- a/python/packages/agent-hooks/pyproject.toml +++ b/python/packages/agent-hooks/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.11.0,<2", - "agent-hooks-sdk>=0.1.0a3,<0.2", + "agent-hooks-sdk>=0.1.0a4,<0.2", ] [tool.uv] diff --git a/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py b/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py index 7f465c3242d..89fb1fe9ef9 100644 --- a/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py +++ b/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py @@ -10,6 +10,8 @@ FunctionInvocationContext, MiddlewareTermination, ) +from agent_hooks import Decision, Transform, Verdict + from agent_framework_agent_hooks import ( AgentHooksChatMiddleware, AgentHooksFunctionMiddleware, @@ -18,28 +20,25 @@ class _AllowAll: - def intercept(self, context: dict[str, Any]) -> dict[str, Any]: - return {"decision": "allow"} + def intercept(self, context: dict[str, Any]) -> Verdict: + return Verdict(decision=Decision.ALLOW) class _DenyTool: def __init__(self, name: str) -> None: self._name = name - def intercept(self, context: dict[str, Any]) -> dict[str, Any]: + def intercept(self, context: dict[str, Any]) -> Verdict: if context["interception_point"] == "pre_tool_call" and context["tool_call"]["name"] == self._name: - return {"decision": "deny", "reason": "blocked_tool"} - return {"decision": "allow"} + return Verdict.deny(reason="blocked_tool") + return Verdict(decision=Decision.ALLOW) class _RedactArg: - def intercept(self, context: dict[str, Any]) -> dict[str, Any]: + def intercept(self, context: dict[str, Any]) -> Verdict: if context["interception_point"] == "pre_tool_call": - return { - "decision": "transform", - "transform": {"path": "$target.query", "value": "[redacted]"}, - } - return {"decision": "allow"} + return Verdict(decision=Decision.TRANSFORM, transform=Transform(path="$target.query", value="[redacted]")) + return Verdict(decision=Decision.ALLOW) class _FakeFunction: From add1a8e6abe0505ea59bb663035f187406e546ee Mon Sep 17 00:00:00 2001 From: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:30:33 +0000 Subject: [PATCH 4/5] style: import order per ruff Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> --- .../packages/agent-hooks/tests/test_agent_hooks_middleware.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py b/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py index 89fb1fe9ef9..61c3e915ea5 100644 --- a/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py +++ b/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py @@ -10,13 +10,12 @@ FunctionInvocationContext, MiddlewareTermination, ) -from agent_hooks import Decision, Transform, Verdict - from agent_framework_agent_hooks import ( AgentHooksChatMiddleware, AgentHooksFunctionMiddleware, agent_hooks_middleware, ) +from agent_hooks import Decision, Transform, Verdict class _AllowAll: From 00ea9f5a0ef4eb5fc4b3631f63df9b3479dc204a Mon Sep 17 00:00:00 2001 From: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:32:48 +0000 Subject: [PATCH 5/5] fix(python): post_tool_call on tool error; defensive result projection A raising tool invocation still emits post_tool_call with tool_result.is_error before the exception propagates, and tool results are projected to JSON-safe values before marshalling so an exotic result type cannot crash the record path. Covered by a new test. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com> --- .../_middleware.py | 34 +++++++++++++++++-- .../tests/test_agent_hooks_middleware.py | 25 ++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/python/packages/agent-hooks/agent_framework_agent_hooks/_middleware.py b/python/packages/agent-hooks/agent_framework_agent_hooks/_middleware.py index 77037be28f8..cd0e718ea29 100644 --- a/python/packages/agent-hooks/agent_framework_agent_hooks/_middleware.py +++ b/python/packages/agent-hooks/agent_framework_agent_hooks/_middleware.py @@ -115,6 +115,21 @@ def _arguments_to_dict(arguments: Any) -> dict[str, Any]: return {"value": str(arguments)} +def _result_to_wire(value: Any) -> Any: + """Best-effort JSON projection of a tool result for post_tool_call.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, Mapping): + return {str(k): _result_to_wire(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_result_to_wire(v) for v in value] + dump = getattr(value, "model_dump", None) + if callable(dump): + with contextlib.suppress(Exception): + return dump() + return str(value) + + class AgentHooksAgentMiddleware(AgentMiddleware): """Run bracket: ``agent_startup``, ``input``, ``output``, ``agent_shutdown``.""" @@ -247,11 +262,26 @@ async def process(self, context: FunctionInvocationContext, call_next: Callable[ args = dict(effective) context.arguments = args - await call_next() + try: + await call_next() + except MiddlewareTermination: + raise + except BaseException as exc: + # The invocation completed with an error: the contract still + # brackets it with post_tool_call (tool_result.is_error = true). + with contextlib.suppress(InterceptionBlocked): + await state.emitter.emit( + state.builder.post_tool_call( + call_id=call_id, name=name, args=args, value=type(exc).__name__, is_error=True + ) + ) + raise try: await state.emitter.emit( - state.builder.post_tool_call(call_id=call_id, name=name, args=args, value=context.result) + state.builder.post_tool_call( + call_id=call_id, name=name, args=args, value=_result_to_wire(context.result) + ) ) except InterceptionBlocked as exc: context.result = None diff --git a/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py b/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py index 61c3e915ea5..033fd5d888e 100644 --- a/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py +++ b/python/packages/agent-hooks/tests/test_agent_hooks_middleware.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. """Tests for the AGENT-HOOKS-0.1 middleware.""" +from collections.abc import Awaitable, Callable from typing import Any import pytest @@ -58,7 +59,11 @@ def _function_context(arguments: dict[str, Any]) -> FunctionInvocationContext: return FunctionInvocationContext(function=_FakeFunction(), arguments=arguments) -async def _run(middlewares: list[Any], fn_ctx: FunctionInvocationContext) -> list[Any]: +async def _run( + middlewares: list[Any], + fn_ctx: FunctionInvocationContext, + tool: Callable[[], Awaitable[None]] | None = None, +) -> list[Any]: """Drive the agent middleware bracket around one function invocation.""" agent_mw, _, fn_mw = middlewares records: list[Any] = [] @@ -67,7 +72,7 @@ async def inner() -> None: async def call_fn() -> None: fn_ctx.result = "ok" - await fn_mw.process(fn_ctx, call_fn) + await fn_mw.process(fn_ctx, tool or call_fn) agent_ctx = _agent_context() await agent_mw.process(agent_ctx, inner) @@ -140,3 +145,19 @@ async def call_next() -> None: await AgentHooksFunctionMiddleware().process(fn_ctx, call_next) assert fn_ctx.result == "ran" + + +async def test_tool_error_still_emits_post_tool_call() -> None: + records: list[Any] = [] + middlewares = agent_hooks_middleware([_AllowAll()], record_sink=records.append) + fn_ctx = _function_context({"query": "cats"}) + + async def exploding_tool() -> None: + raise RuntimeError("tool exploded") + + with pytest.raises(RuntimeError, match="tool exploded"): + await _run(middlewares, fn_ctx, tool=exploding_tool) + + posts = [r for r in records if r.interception_point.value == "post_tool_call"] + assert len(posts) == 1 + assert records[-1].interception_point.value == "agent_shutdown"