Skip to content

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

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

MohammadHaroonAbuomar wants to merge 4 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: a value built in code, a built
agent_hooks.HostDeclaration, or the path of a document file. The existing factories and
their behaviour stay as they are.

Fixes #9329.

Description & Review Guide

  • What are the major changes?

    One new public function 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, configure_registry=None, record_sink=None
    )

    declaration takes one of three forms. An agent_hooks.HostDeclaration (what
    agent_hooks.DeclarationBuilder.build() returns) is used as is. Any Mapping is copied
    into a dict and read through agent_hooks.HostDeclaration.from_value. A str or
    os.PathLike is the path of the document file, read once through
    agent_hooks.HostDeclaration.from_path (UTF-8, at most 1 MiB; an unreadable file is
    agent_hooks.DeclarationError with class unreadable). A string is always a path; JSON
    text goes through agent_hooks.HostDeclaration.from_json(text) first.

    configure_registry is an optional callback that receives a fresh
    agent_hooks.HostRegistry the factory builds for that call 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). The factory registers agent_framework.interceptor when interceptors is
    given, runs the callback so the host registers its own binding kinds, identity providers,
    approval resolvers and redactors, then validates the document against the registry. The
    registry never leaves the factory: it is kept for the per-run reload and nothing
    caller-owned is mutated, so a refused document or a raising callback leaves nothing
    behind for the next call. A callback that registers agent_framework.interceptor while
    interceptors is also given hits the SDK's own "registered twice" ValueError.

    The per-call registry and the callback mirror the .NET change in .NET: build agent-hooks enforcement from a host declaration document #9406
    (Action<HostRegistry> configureRegistry). Three things differ on purpose: Python keeps
    the callback optional because it has a built-in kind, agent_framework.interceptor, fed
    by interceptors (the .NET package registers no built-in kind, so there the callback is
    required); Python folds the path form into the one factory and has no JSON-text entry
    (.NET keeps FromDeclaration, FromDeclarationPath, FromDeclarationJson and
    FromDeclarationNode). Both refuse a document with zero bindings at construction, since an
    emitter with zero interceptors fails closed on every emission.

    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(
                "declaration.json", interceptors={"egress": EgressGuard()}
            )
        ],
    )

    A host kind registered through the callback:

    def configure(registry):
        registry.kind("acme.guard", acme_guard)
    
    
    bundle = create_agent_hooks_middleware_from_declaration(
        "policy/declaration.json", configure_registry=configure
    )
  • 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 point.

    tests/core/test_agent_hooks.py gains a "Host declaration" region with 24 test
    functions (38 cases) plus a third case of the telemetry test. They run offline against
    the mock chat client and cover: a value, a built HostDeclaration, a str path and an
    os.PathLike path 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 are refused at construction (ValueError); a lambda callback works; per-point
    at sets dispatch only where bound; mode and approval.resolver come from the
    document; a kind, identity provider, approval resolver and redactor registered through
    configure_registry are honoured, and a host kind receives the binding context (id,
    kind, at, timeout, contract version); one callback works again after a refused
    document and after the callback itself raised; two factories share one callback and one
    interceptors mapping and run separate sessions; a callback that registers
    agent_framework.interceptor while interceptors is given raises ValueError, and the
    same inputs without the clash build; any Mapping loads and a non-mapping value gets
    the SDK's malformed refusal; a missing file, and JSON text given as a path, are
    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 the per-call registry behind configure_registry (the factory builds it,
    the callback fills it, nothing caller-owned is touched).

Related Issue

Fixes #9329

A maintainer ran /plan on the issue on 2026-10-13 and this change follows the generated
plan: one factory that takes the document as a value, a built HostDeclaration or a file
path. It differs in one place: the plan's factory also took JSON text; here JSON text goes
through agent_hooks.HostDeclaration.from_json first, and a string is always a path. The
plan left open whether the 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 Outdated
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>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

MohammadHaroonAbuomar _bundle_from_declaration() registers agent_framework.interceptor directly on the caller-supplied HostRegistry before declaration validation. Because HostRegistry.kind() mutates in place, a failed factory call leaves the reusable registry poisoned; a later factory can fail as already registered or unexpectedly reuse the first interceptor resolver. Please build or clone a per-factory registry instead of mutating the caller-owned one, with coverage for reuse after a failed load and across two declaration factories.

@eavanvalkenburg

Copy link
Copy Markdown
Member

I have not had a good look at Agent Hooks since they were added, but I have a feeling that we need to consolidate these helper methods because at some point there are diminishing returns when you have 5 or 6 functions that all do roughly the same thing, that might be better as a followup and something we can do before we remove the experimental tags, but could you already have a look at that MohammadHaroonAbuomar

Review of the host-declaration factories asked for two changes. The
helpers had grown to five or six functions that did roughly the same
thing, and the shared helper registered agent_framework.interceptor on
the caller-supplied HostRegistry before validation. HostRegistry.kind()
mutates in place, so a refused document or a raising kind resolver left
the caller's registry holding the kind: the next factory call failed as
already registered, or reused the first call's resolver.

create_agent_hooks_middleware_from_declaration is now the only
declaration factory. It accepts a built HostDeclaration, any Mapping
(copied into a dict for the SDK), or a file path as str or os.PathLike,
read once through HostDeclaration.from_path. JSON text is not accepted
directly; HostDeclaration.from_json covers it. The registry keyword is
replaced by configure_registry, a callback that receives a fresh
HostRegistry built for that call over this framework's surface, after
agent_framework.interceptor is registered when interceptors is given
and before the document is validated. The registry never leaves the
factory and nothing caller-owned is mutated, so a refused document or
a raising callback cannot affect a later call. A callback that
registers agent_framework.interceptor while interceptors is also given
hits the SDK's own "registered twice" ValueError. The callback's return
value is ignored (Callable[[HostRegistry], object]), so a lambda over
the chaining registry methods works. A document with no bindings is
refused at construction with ValueError, the rule
create_agent_hooks_middleware already applies, since an emitter with
zero interceptors fails closed on every emission. This mirrors the .NET
factories, which take Action<HostRegistry> configureRegistry and refuse
zero bindings the same way.

Removed: create_agent_hooks_middleware_from_declaration_path,
create_agent_hooks_middleware_from_declaration_json and
create_agent_hooks_host_registry, with the surface check they needed,
from the module, the lazy export map, the stub and PACKAGE_STATUS.md.

Tests cover reuse of one callback after a refused document and after
the callback itself raised, two factories sharing one callback and one
interceptors mapping, the registered-twice clash, str and os.PathLike
paths, a missing file, JSON text given as a path, a lambda callback, a
document with no bindings, and a kind, identity provider, approval
resolver and redactor registered through the callback. The per-run
reload and the from_emitter single-session tests stay.

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

MohammadHaroonAbuomar commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Both points taken, and done in 082a8ff.

The registry: the factory no longer takes a HostRegistry. It builds a fresh one on every call and hands it to an optional configure_registry callback, where the host registers its own kinds, identity providers, approval resolvers and redactors. The registry never leaves the factory, so a refused document or a raising callback cannot poison anything the caller owns. Tests cover reuse of one callback after a refused load and after a raising callback, two factories sharing one callback and one interceptors mapping, and the clash when a callback registers agent_framework.interceptor while interceptors is also given. The per-call registry and the callback are the shape the .NET change in #9406 already has (Action<HostRegistry> configureRegistry), with two differences: Python keeps the callback optional because it has the built-in agent_framework.interceptor kind fed by interceptors, and it folds the path form into the one factory with no JSON-text entry. A document with no bindings is refused at construction on both sides, the rule create_agent_hooks_middleware already applied.

The helpers: the three new names are now one. create_agent_hooks_middleware_from_declaration takes a HostDeclaration, a mapping, or a path (str or os.PathLike); JSON text goes through agent_hooks.HostDeclaration.from_json first. create_agent_hooks_middleware_from_declaration_path, create_agent_hooks_middleware_from_declaration_json and create_agent_hooks_host_registry are gone, so this PR adds one public function instead of four. The two pre-existing factories, create_agent_hooks_middleware and create_agent_hooks_middleware_from_emitter, are untouched here. Folding the emitter one into the main factory as a keyword is the natural next step, and that can go in the follow-up you suggested, before the experimental tag comes off.

This branch was successfully deployed

1 active deployment
github-app-auth — 37a4e391 Deployed Oct 16, 2026 by MohammadHaroonAbuomar via add_label #25584
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

3 participants