Skip to content

Python: feat(core): load the agent-hooks middleware from a host declaration - #9405

Open
MohammadHaroonAbuomar wants to merge 2 commits into
microsoft:mainfrom
MohammadHaroonAbuomar:agent-hooks-declaration-loading
Open

MohammadHaroonAbuomar wants to merge 2 commits into
microsoft:mainfrom
MohammadHaroonAbuomar:agent-hooks-declaration-loading

Conversation

@MohammadHaroonAbuomar

@MohammadHaroonAbuomar MohammadHaroonAbuomar commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Motivation & Context

Agent Hooks 0.1.0-beta.2 adds the host declaration document (spec section 7.7, contract
agent-hooks-declaration/1.0). One JSON file states a host's enforcement configuration
(mode, composition, identity provider, approval resolver and redactor, timeouts, record
bound), its declared surface and its interceptor bindings. The loader checks the document
against what the host's code can honour and refuses anything else before any emission.
Records from an emitter built this way carry the contract version, so an audit can tell a
declared host from one configured in code.

The Python middleware (#7515) takes its configuration from factory arguments only. This
change lets an operator hand the agent a document instead, from a file, from JSON text or
from a value built in code. The existing factories and their behaviour stay as they are.

Fixes #9329.

Description & Review Guide

  • What are the major changes?

    New public functions in agent_framework/_agent_hooks.py, exported from
    agent_framework and marked experimental under AGENT_HOOKS like the two existing
    factories (so ExperimentalWarning fires on each call, as it does today):

    • create_agent_hooks_middleware_from_declaration(declaration, *, interceptors=None, registry=None, record_sink=None)
      takes the document as a mapping or an agent_hooks.HostDeclaration (what
      agent_hooks.DeclarationBuilder.build() returns).
    • create_agent_hooks_middleware_from_declaration_path(path, *, ...) reads the file
      once through agent_hooks.HostDeclaration.from_path.
    • create_agent_hooks_middleware_from_declaration_json(json_str, *, ...) goes through
      agent_hooks.HostDeclaration.from_json.
    • create_agent_hooks_host_registry() returns an agent_hooks.HostRegistry over the
      surface this middleware implements: all eight points; model_calls, tool_calls,
      int64_json, bigint_json, host_declaration; posture
      tool_seam_host_error: terminate; buffered streaming. Hosts register their own
      binding kinds, identity providers, approval resolvers and redactors on it and pass
      it as registry. The conformance harness also needs the surface.

    The names follow the declarative package (create_agent_from_yaml_path,
    create_agent_from_yaml, create_agent_from_dict).

    How it works:

    • The factory loads the document once at construction through
      InterceptionEmitter.from_declaration. A refused document raises
      agent_hooks.DeclarationError there, before any run, with the spec's refusal class
      (unknown_field, version_unsupported, kind_unknown, binding_rejected and so
      on).
    • Each run then loads a fresh sealed emitter from the same HostDeclaration and
      HostRegistry. That keeps the one-session-per-run model of
      create_agent_hooks_middleware: agent_startup and agent_shutdown bracket every
      run, and session ids and sequences restart per run. Kind resolvers run once at
      construction and once per run; the spec asks them to be cheap and to do no I/O
      before the interceptor's first call. A host that wants one session across runs
      builds the emitter with InterceptionEmitter.from_declaration_path(...) and passes
      it to create_agent_hooks_middleware_from_emitter; a test pins that path.
    • Passing interceptors={"egress": EgressGuard()} registers the framework's own
      binding kind, agent_framework.interceptor. A binding
      {"id": "egress", "kind": "agent_framework.interceptor"} runs the interceptor
      supplied under its id; config: {"name": "..."} picks another key. The resolver
      refuses any other config shape and never echoes the config in its message. The bound
      instances are the ones the caller supplied, so they stay shared across runs as today.
      A kind is a lookup key only; the loader never imports a module or class named in a
      document.
    • A document that omits configuration.posture gets the spec default, continue,
      and the loader refuses it against this framework's surface (surface_unsupported:
      the host implements terminate). Documents for Agent Framework write
      "posture": {"tool_seam_host_error": "terminate"}. The docstrings say so.
    • An installed agent-hooks-sdk that predates the loader gets an ImportError with
      an upgrade hint (agent-hooks-sdk 0.1.0b2 or later).
    • The workspace dev pin moves from agent-hooks-sdk>=0.1.0a4,<0.2 to
      >=0.1.0b2,<0.2 and uv.lock follows (uv lock --upgrade-package agent-hooks-sdk),
      so the new tests run in CI instead of skipping. The runtime dependency is unchanged:
      the SDK stays optional.

    Smallest document that enforces one interceptor:

    {
      "declaration": "agent-hooks-declaration/1.0",
      "configuration": {"posture": {"tool_seam_host_error": "terminate"}},
      "bindings": [{"id": "egress", "kind": "agent_framework.interceptor"}]
    }
    agent = Agent(
        client=client,
        middleware=[
            create_agent_hooks_middleware_from_declaration_path(
                "declaration.json", interceptors={"egress": EgressGuard()}
            )
        ],
    )
  • What is the impact of these changes?

    Additive. create_agent_hooks_middleware and
    create_agent_hooks_middleware_from_emitter are unchanged, and so are the three
    middleware classes. The only change on the run path is one branch in
    _new_run_state that calls the loader when the config holds a declaration.
    PACKAGE_STATUS.md lists the new entry points.

    tests/core/test_agent_hooks.py gains a "Host declaration" region with 17 test
    functions (24 cases) plus a third case of the telemetry test. They run offline against
    the mock chat client and cover: the value, JSON and path factories produce the same
    records; every record carries declaration: "agent-hooks-declaration/1.0" and the
    binding id as the verdict name; one session per run; each refusal class raises at
    construction; the posture requirement; agent_framework.interceptor binds by name or
    id and refuses bad config without echoing it; zero bindings fail closed
    (host_error:no_interceptor, no model call); per-point at sets dispatch only where
    bound; mode and approval.resolver come from the document; a host-registered kind
    receives the binding context (id, kind, at, timeout, contract version); a registry
    that already holds the framework kind is rejected; a built HostDeclaration is
    accepted; a missing file is unreadable; streaming stays buffered; a
    declaration-built emitter works with the host-owned-session factory.

    The new tests skip with a clear reason when the installed SDK lacks the loader, so an
    environment on an older pin still passes.

  • What do you want reviewers to focus on?

    The per-run reload in _new_run_state (a sealed emitter cannot be reused for a new
    session, so each run loads one from the same document and registry), the
    agent_framework.interceptor kind resolver (config validation, no config echo in
    refusals), and whether create_agent_hooks_host_registry() should be public or stay
    behind the three factories.

Related Issue

Fixes #9329

A maintainer ran /plan on the issue on 2026-10-13 and this change follows the generated
plan. It differs in one place: the plan proposed one factory that accepts a document, a
path or JSON text; this PR splits those into three factories, named after the declarative
package's create_agent_from_yaml_path, create_agent_from_yaml and
create_agent_from_dict. The plan left open whether the default registry's capability
list should mirror the existing conformance claim; it does.

AI Assistance

  • No material AI assistance was used.
  • This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.

AI assistance details: Implementation, tests and this description were drafted with AI assistance and reviewed, run and verified locally.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

…ration

Add three factories that build the agent-hooks middleware bundle from a
host declaration document (agent-hooks spec section 7.7, contract
agent-hooks-declaration/1.0): from a mapping or a built HostDeclaration,
from a file path, and from JSON text. Add create_agent_hooks_host_registry,
which returns an agent_hooks.HostRegistry over the surface this middleware
implements (all eight points, the claimed capabilities, posture terminate,
buffered streaming), so hosts can register their own binding kinds,
identity providers, approval resolvers and redactors.

The factory loads the document once at construction so a refused document
raises agent_hooks.DeclarationError before any run. Each run then loads a
fresh sealed emitter from the same document and registry, which keeps the
one-session-per-run model of create_agent_hooks_middleware. Passing
interceptors registers the framework's own binding kind,
agent_framework.interceptor, which binds a supplied interceptor by name or
binding id and refuses any other config without echoing it.

The existing factories and the three middleware classes are unchanged. The
new tests skip when the installed agent-hooks-sdk predates the loader.
Move the dev pin to agent-hooks-sdk 0.1.0b2, the first release with the
loader, and refresh uv.lock so the tests run in CI.

Fixes microsoft#9329

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Supplied registries can bypass framework-surface validation, and valid non-dict mapping inputs are rejected.

2 open findings
What changed in this PR

Adds declaration-based configuration to Python’s Agent Hooks middleware while preserving the existing factories and per-run sessions.

Changes:

  • Adds value, JSON, and file factories plus a public host registry.
  • Adds declaration validation, binding, session, and streaming tests.
  • Updates exports, experimental-feature documentation, and the development SDK dependency.
File Description
python/​uv.lock Locks the updated Agent Hooks SDK.
python/​pyproject.toml Raises the development SDK minimum.
python/​packages/​core/​tests/​core/​test_agent_hooks.py Tests declaration-based middleware.
python/​packages/​core/​agent_framework/​_agent_hooks.py Implements factories, registry, and per-run loading.
python/​packages/​core/​agent_framework/​__init__.pyi Exposes typed public entry points.
python/​packages/​core/​agent_framework/​__init__.py Adds lazy public exports.
python/​PACKAGE_STATUS.md Documents new experimental APIs and SDK requirements.

🧠 Review effort: Balanced


💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/packages/core/agent_framework/_agent_hooks.py
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
…surface and accept any Mapping

The declaration factories handed a caller-supplied registry to the SDK
as given, and the SDK validates a document against that registry's own
surface. A registry built over another surface (the SDK default, with
posture continue, or one that streams unbuffered) therefore accepted
documents this middleware cannot honour: a document that omits the
posture, or one that declares buffered_output: false. The factories now
refuse a registry whose surface differs from the one the middleware
implements, with a ValueError that names the differing members and
points at create_agent_hooks_host_registry(). A registry over the same
surface keeps its registered kinds, identity providers, approval
resolvers and redactors, so a document is always checked against the
middleware's surface and the caller's names.

create_agent_hooks_middleware_from_declaration accepts a Mapping, but
HostDeclaration.from_value serializes with json.dumps, which takes a
dict and refuses other mappings such as MappingProxyType or UserDict
with declaration_error:malformed. The factory now copies a top-level
Mapping into a dict before handing it to the SDK. Anything that is not
a mapping still reaches the SDK as given, so its own refusal applies.

Tests cover a registry with another posture, one that advertises
unbuffered streaming, a hand-built registry over the framework surface
that keeps its registered names, MappingProxyType and UserDict
documents, and non-mapping values that get the SDK's refusal.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
github-app-auth — 38f3e4e3 Deployed Oct 15, 2026 by MohammadHaroonAbuomar via add_label #25416
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: build the Agent Hooks middleware from a host declaration document

2 participants