Repository navigation
Python: feat(core): load the agent-hooks middleware from a host declaration - #9405
MohammadHaroonAbuomar wants to merge 4 commits into
Conversation
…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>
There was a problem hiding this comment.
🟡 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.
…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>
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
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.
|
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>
|
Both points taken, and done in 082a8ff. The registry: the factory no longer takes a The helpers: the three new names are now one. |

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 andtheir 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 fromagent_frameworkand marked experimental underAGENT_HOOKSlike the two existingfactories (so
ExperimentalWarningfires on each call, as it does today):declarationtakes one of three forms. Anagent_hooks.HostDeclaration(whatagent_hooks.DeclarationBuilder.build()returns) is used as is. AnyMappingis copiedinto a
dictand read throughagent_hooks.HostDeclaration.from_value. Astroros.PathLikeis the path of the document file, read once throughagent_hooks.HostDeclaration.from_path(UTF-8, at most 1 MiB; an unreadable file isagent_hooks.DeclarationErrorwith classunreadable). A string is always a path; JSONtext goes through
agent_hooks.HostDeclaration.from_json(text)first.configure_registryis an optional callback that receives a freshagent_hooks.HostRegistrythe factory builds for that call over the surface thismiddleware implements (all eight points;
model_calls,tool_calls,int64_json,bigint_json,host_declaration; posturetool_seam_host_error: terminate; bufferedstreaming). The factory registers
agent_framework.interceptorwheninterceptorsisgiven, 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.interceptorwhileinterceptorsis 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 keepsthe callback optional because it has a built-in kind,
agent_framework.interceptor, fedby
interceptors(the .NET package registers no built-in kind, so there the callback isrequired); Python folds the path form into the one factory and has no JSON-text entry
(.NET keeps
FromDeclaration,FromDeclarationPath,FromDeclarationJsonandFromDeclarationNode). Both refuse a document with zero bindings at construction, since anemitter with zero interceptors fails closed on every emission.
How it works:
InterceptionEmitter.from_declaration. A refused document raisesagent_hooks.DeclarationErrorthere, before any run, with the spec's refusal class(
unknown_field,version_unsupported,kind_unknown,binding_rejectedand soon).
HostDeclarationandHostRegistry. That keeps the one-session-per-run model ofcreate_agent_hooks_middleware:agent_startupandagent_shutdownbracket everyrun, 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 passesit to
create_agent_hooks_middleware_from_emitter; a test pins that path.interceptors={"egress": EgressGuard()}registers the framework's ownbinding kind,
agent_framework.interceptor. A binding{"id": "egress", "kind": "agent_framework.interceptor"}runs the interceptorsupplied under its id;
config: {"name": "..."}picks another key. The resolverrefuses 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.
configuration.posturegets 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.agent-hooks-sdkthat predates the loader gets anImportErrorwithan upgrade hint (
agent-hooks-sdk0.1.0b2 or later).agent-hooks-sdk>=0.1.0a4,<0.2to>=0.1.0b2,<0.2anduv.lockfollows (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"}] }A host kind registered through the callback:
What is the impact of these changes?
Additive.
create_agent_hooks_middlewareandcreate_agent_hooks_middleware_from_emitterare unchanged, and so are the threemiddleware classes. The only change on the run path is one branch in
_new_run_statethat calls the loader when the config holds a declaration.PACKAGE_STATUS.mdlists the new entry point.tests/core/test_agent_hooks.pygains a "Host declaration" region with 24 testfunctions (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, astrpath and anos.PathLikepath produce the same records; every record carriesdeclaration: "agent-hooks-declaration/1.0"and the binding id as the verdict name; onesession per run; each refusal class raises at construction; the posture requirement;
agent_framework.interceptorbinds by name or id and refuses bad config without echoingit; zero bindings are refused at construction (
ValueError); a lambda callback works; per-pointatsets dispatch only where bound;modeandapproval.resolvercome from thedocument; a kind, identity provider, approval resolver and redactor registered through
configure_registryare honoured, and a host kind receives the binding context (id,kind,
at, timeout, contract version); one callback works again after a refuseddocument and after the callback itself raised; two factories share one callback and one
interceptorsmapping and run separate sessions; a callback that registersagent_framework.interceptorwhileinterceptorsis given raisesValueError, and thesame inputs without the clash build; any
Mappingloads and a non-mapping value getsthe SDK's
malformedrefusal; a missing file, and JSON text given as a path, areunreadable; streaming stays buffered; a declaration-built emitter works with thehost-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 newsession, so each run loads one from the same document and registry), the
agent_framework.interceptorkind resolver (config validation, no config echo inrefusals), 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
/planon the issue on 2026-10-13 and this change follows the generatedplan: one factory that takes the document as a value, a built
HostDeclarationor a filepath. It differs in one place: the plan's factory also took JSON text; here JSON text goes
through
agent_hooks.HostDeclaration.from_jsonfirst, and a string is always a path. Theplan left open whether the registry's capability list should mirror the existing
conformance claim; it does.
AI Assistance
AI assistance details: Implementation, tests and this description were drafted with AI assistance and reviewed, run and verified locally.
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.