Skip to content

.NET: agent-hooks interception contract as a first-class experimental feature - #7564

Merged
westey (westey-m) merged 14 commits into
microsoft:mainfrom
MohammadHaroonAbuomar:mhabuomar/agent-hooks-dotnet
Aug 19, 2026
Merged

.NET: agent-hooks interception contract as a first-class experimental feature#7564
westey (westey-m) merged 14 commits into
microsoft:mainfrom
MohammadHaroonAbuomar:mhabuomar/agent-hooks-dotnet

Conversation

@MohammadHaroonAbuomar

@MohammadHaroonAbuomarMohammadHaroonAbuomar commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Motivation & Context

.NET counterpart of the agent-hooks feature merged for Python in #7515: the AGENT-HOOKS-0.1 interception contract as a first-class experimental feature, with the same enforcement semantics against the same spec. The .NET side was deliberately deferred in the Python PR; this PR completes the pair, so hosts get one portable interception/enforcement contract (interceptors, verdicts, records, approval seam) across both languages.

Description & Review Guide

  • What are the major changes?
    • New package Microsoft.Agents.AI.AgentHooks (+ Microsoft.Agents.AI.AgentHooks.UnitTests). No existing framework source is modified — the only shared-file edits are the central package pin (ResponsibleAI.AgentHooks 0.1.0-alpha.4, which bundles native runtimes for linux-x64/osx/win-x64 so tests run on the CI matrix) and the two solution entries.
    • One public factory, AsAIAgentWithAgentHooks(this IChatClient, ...) (per-run-session and host-owned-session overloads, both [Experimental("MAAI001")]), composes the enforcement as one indivisible unit — the seam decorators are internal, so partial installs are impossible by construction:
      • agent seam (DelegatingAIAgent): agent_startup/input/output/agent_shutdown, per-run state, buffered streaming, persistence gate;
      • chat seam (DelegatingChatClient installed belowFunctionInvokingChatClient): pre_model_call/post_model_call per model service call, buffered streaming per call;
      • function seam (function-invocation middleware): pre_tool_call/post_tool_call, argument/result write-back, blocked-call tool-error payloads, host_error halts via FunctionInvocationContext.Terminate (the loop's only loud escape).
    • Fail-closed three-verdict enforcement everywhere: denies block before execution/incorporation (run-level denies surface as InterceptionBlockedException; streaming releases zero updates ahead of a verdict), transforms execute as native content (rich content preserved as AIContent, never flattened), interceptor crashes become host_error denies.
    • Verdict-before-durability: end-of-run history and context-provider writes defer behind the output verdict (dropped on deny, flushed post-transform — with verdicted-message substitution for streamed runs); the implicit default InMemoryChatHistoryProvider is materialized and gated too, with the history-conflict flags set to mimic implicit-default semantics; per-service-call persistence sits above the chat seam and is covered by its own post_model_call verdict; per-run provider overrides are wrapped in bothAdditionalProperties dictionaries, copy-on-write; nested guarded sub-agents persist inline at their own boundaries.
    • Misuse fails closed instead of silently weakening the surface: a caller-supplied per-run ChatClientAgentRunOptions.ChatClientFactory is rejected (it would replace the guarded pipeline and the tool-wrapping stage) — the framework's own function-invocation middleware factory is recognized and allowed, so composing function middleware outside the guarded agent works, with its factory chain walked so a caller factory cannot ride in unnoticed — and a supplied client already containing a FunctionInvokingChatClient is rejected (tools would execute below the verdicts).
    • Documented limitations: hosted (service-executed) tools never hit the function seam and are intercepted via the post_model_call content projection; service-managed (conversation-id) history is durable at the service and ungateable by any framework layer; the built-in deferred-OTel decorator sits above the chat seam, so sensitive-data request spans see pre-transform content (an observer channel inside the boundary); a chat-seam projection failure fails the run closed but currently without a synthesized host_error record — an SDK affordance gap tracked in SDK: no emitter affordance to synthesize a host_error record for host-side projection failures responsibleai/agent-hooks#70 (same gap exists in the merged Python feature).
  • What is the impact of these changes? None for existing users: the feature is opt-in via a new package; core builds and behavior are untouched. The package ships in the release solution filter as a preview package (the repo's default suffix for unmarked packages; per maintainer review) — the whole surface stays [Experimental]; ADR docs/decisions/0035-dotnet-agent-hooks-enforcement.md records the seam order, durability gating, fail-closed behavior, alternatives and limitations; a runnable sample follows once the API shape settles in review.
  • What do you want reviewers to focus on? The ChatClientAgent boundary hardenings: the default-provider gating and its conflict-flag mimicry (Warn/Throw=false, Clear=true to preserve implicit-default semantics for service-managed history), the two-dictionary run-option override wrapping, and the two loud rejections (ChatClientFactory, pre-existing FunctionInvokingChatClient). These carry the three structural claims (indivisibility, verdict-before-durability, seam order).

Evidence: 84 unit tests, all green (deny-before-execution and transform write-back at every seam, rich-content preservation, streaming ordering with zero egress on deny, error bracketing, concurrency isolation, host-owned sessions, evaluate_only, approval-seam lift, full persistence-gating matrix, misuse rejections, codec units); Debug+Release builds on net10/net9/net8 with warnings-as-errors; dotnet format clean. The change went through two independent internal review rounds (architecture + adversarial, 25 probes total); every finding was fixed with a regression test — the runtime enforcement held throughout, all findings were at the structural boundary hardened above. Known Python-parity quirks tracked in #7561 apply to this port equally by design (same semantics until fixed in both).

Related Issue

Fixes#7563 (the .NET continuation of the feature merged in #7515).

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 is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change.

…ckage
Add Microsoft.Agents.AI.AgentHooks, implementing the AGENT-HOOKS-0.1
control contract on the framework's native decorator seams, mirroring
the merged Python feature (microsoft#7515) in .NET idiom:
- One public factory (CreateAIAgentWithAgentHooks, per-run and
host-owned-session overloads) composes agent, chat and function
seams as one indivisible unit; the seam decorators are internal, so
partial installs are impossible by construction.
- All eight interception points: input/output at the agent seam,
pre/post_model_call below the function-invocation loop (every model
service call bracketed individually), pre/post_tool_call via the
function-invocation middleware seam, agent_startup/agent_shutdown
bracketing each run.
- Fail-closed enforcement throughout: transforms write back into the
native messages/arguments/results or throw; rich content is
preserved as AIContent objects; interceptor crashes surface as
host_error denies; enforcement-layer failures halt the run through
FunctionInvocationContext.Terminate (the loop's only loud escape).
- Streaming is fully buffered per spec buffered_output semantics: a
deny releases zero updates; transformed responses re-derive the
released updates so egress never diverges from verdicted content.
- Verdict-before-durability: end-of-run history and context-provider
writes defer behind the output verdict via gating provider wrappers
(flushed post-transform with verdicted-message substitution for
streams, dropped on deny); per-service-call persistence sits above
the chat seam and is covered by its own post_model_call verdict;
per-run history-provider overrides in run options are wrapped too;
nested guarded sub-agents persist inline at their own boundaries.
- Opt-in dependency: ResponsibleAI.AgentHooks 0.1.0-alpha.4 (bundles
native runtimes) referenced only by the new package; no existing
framework source is modified.
- 58 tests: deny-before-execution and transform write-back per seam,
rich-content preservation, streaming ordering with zero egress on
deny, error bracketing, concurrency isolation, host-owned sessions,
evaluate_only, approval-seam lift, persistence gating, misuse
fail-closed paths, and codec units.
Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Address both reviewers' probe-confirmed findings; the runtime
enforcement held everywhere, every fix is at the structural boundary:
- Gate the implicit default ChatHistoryProvider: with no provider
configured, ChatClientAgent creates an InMemoryChatHistoryProvider
the factory never saw, so denied output became durable session
history and replayed to the model on the zero-config path (both
stream modes). The factory now materializes and gates the default,
setting the history-conflict flags to mimic implicit-default
semantics for service-managed-history agents.
- Wrap per-run provider overrides on BOTH dictionaries: base
AgentRunOptions.AdditionalProperties is merged into the chat options
with precedence, so a base-level override bypassed (and displaced)
the wrapped ChatOptions-level entry. Plain AgentRunOptions is
covered too, and the wrap is copy-on-write — the caller's options
and dictionaries are never mutated.
- Reject per-run ChatClientFactory on guarded agents (fail closed): it
would replace the guarded chat pipeline and the tool-wrapping stage
riding it, silently removing the chat and tool seams.
- Reject a supplied client already containing a
FunctionInvokingChatClient: it would execute tools below the chat
seam, before any post_model_call verdict and outside the tool seam.
- Run wire projections inside the guarded blocks at the chat and
function seams: a poisoned value whose serialization throws now
fails the run closed (function seam: host_error halt; chat seam:
gated persistence refused before the failure propagates).
- Suppress provider failure notifications once a run-level deny or
halt stands, so the denied turn's request messages never reach
provider code.
- Document the deferred-OpenTelemetry observer channel (request-side
spans capture pre-transform content under sensitive-data telemetry).
- Rename the factory to AsAIAgentWithAgentHooks per repo convention.
10 new boundary regression tests mined from the review probes
(default-provider durability in both stream modes with session-replay
assertions, both override dictionaries incl. the displacement shape,
plain-run-options override, copy-on-write, factory and supplied-FICC
rejections, poisoned-projection fail-closed); 68 total, all green.
Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
…r kinds
The deny/halt handling of provider failure notifications only covered
the chat-history wrapper; a context provider still received the denied
turn's request messages on its failure notification. Both gating
wrappers now REDACT instead of suppress: the notification is forwarded
with empty request messages and the original exception, preserving the
documented failure-cleanup contract (providers releasing per-run
resources on the failure signal keep working) while the denied turn's
request messages never reach provider code.
Regression tests assert both provider kinds receive the redacted
notification (zero request messages) on a denied run and full
notifications on ordinary, verdict-free failures. 70 tests total.
Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

CopilotAI 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.

Pull request overview

Adds experimental .NET support for the AGENT-HOOKS-0.1 interception contract.

Changes:

  • Adds agent, model, and tool interception seams with fail-closed enforcement.
  • Implements transform codecs, buffered streaming, and persistence gating.
  • Adds comprehensive enforcement, boundary, codec, and persistence tests.
Show a summary per file
FileDescription
dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksAgent.csImplements run-level interception and streaming.
dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksChatClient.csImplements model-call interception.
dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksChatClientExtensions.csExposes the public factory and composes seams.
dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksFunctionMiddleware.csIntercepts host-executed tools.
dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksGatingProviders.csGates provider persistence behind verdicts.
dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksOptions.csDefines public enforcement options.
dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksRunState.csHolds ambient run state and persistence queue.
dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksWireCodecs.csConverts and writes back interception payloads.
dotnet/src/Microsoft.Agents.AI.AgentHooks/Microsoft.Agents.AI.AgentHooks.csprojDefines the new package.
dotnet/tests/Microsoft.Agents.AI.AgentHooks.UnitTests/AgentHooksBoundaryRegressionTests.csTests structural boundary protections.
dotnet/tests/Microsoft.Agents.AI.AgentHooks.UnitTests/AgentHooksCodecTests.csTests wire codecs and write-back.
dotnet/tests/Microsoft.Agents.AI.AgentHooks.UnitTests/AgentHooksEnforcementTests.csTests interception and enforcement semantics.
dotnet/tests/Microsoft.Agents.AI.AgentHooks.UnitTests/AgentHooksPersistenceTests.csTests verdict-before-durability behavior.
dotnet/tests/Microsoft.Agents.AI.AgentHooks.UnitTests/TestInfrastructure.csProvides test guards, providers, and clients.
dotnet/tests/Microsoft.Agents.AI.AgentHooks.UnitTests/Microsoft.Agents.AI.AgentHooks.UnitTests.csprojDefines the test project.
dotnet/Directory.Packages.propsPins the AgentHooks SDK dependency.
dotnet/agent-framework-dotnet.slnxRegisters package and test projects.

Review details

  • Files reviewed: 17/17 changed files
  • Comments generated: 6
  • Review effort level: Balanced

Comment threaddotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksAgent.cs Outdated
Comment threaddotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksAgent.cs Outdated
Comment threaddotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksWireCodecs.cs Outdated
Comment threaddotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksWireCodecs.cs Outdated
Comment threaddotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksWireCodecs.cs Outdated
- Run options: always clone chat-typed run options (the framework's
function-invocation middleware chains its per-run factory onto the
instance it receives, so forwarding the caller's instance leaked
that factory into it — reuse tripped the rejection, concurrent
reuse raced), and recognize the framework middleware's own factory
as legitimate: it wraps the guarded pipeline (tool rewriting), so
outer function-middleware composition now works, while its chained
factories are walked so a caller-supplied factory cannot ride in
unnoticed.
- Streaming: re-derived (transformed) updates preserve the response's
ContinuationToken (ToAgentResponseUpdates does not project it), so
transformed background streaming responses remain resumable; a
message-less response releases a metadata-only update carrying it.
- Codecs: transformed tool calls are validated for complete shape and
uniqueness before reconciliation (non-empty string id and name,
object-valued args, distinct ids) — malformed shapes fail closed
instead of becoming invalid native calls. Deliberately stricter
than the merged Python codec, which coerces added-call shapes.
- Role defaulting in message write-backs is confirmed exact Python
parity (user/assistant defaults per the merged codecs) and is now
locked by tests rather than changed.
- ADR 0035 records the seam order, persistence gating, fail-closed
behavior, alternatives and known limitations.
14 new tests (options reuse, outer function-middleware composition,
smuggled-factory rejection, continuation-token preservation, 8
malformed tool-call shapes, 2 role-default parity); 84 total, green.
Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Jose Luis Latorre Millas (joslat) added a commit to joslat/agent-hooks that referenced this pull request Aug 16, 2026
Two host limitations are disclosed as prose today rather than carried by
the conformance surface: hosted (service-executed) tools never reach the
function seam, and service-managed history is durable outside the host's
gate. Both are the property 12.1a already uses without naming it -- a
seam is retractable when a covering verdict can still prevent the guarded
effect, or prevent it becoming durable, inside the enforcement boundary.
buffered_output is the egress instance; 3.2 point-omission cannot express
these two because both hosts emit all eight points.
Enumerates status quo (A), two declared capabilities (B, recommended),
and a single enumerated unretractable-seam surface (C), with D/E/F set
aside. Both B and C default to the retractable value with false declared
explicitly per 13.1's buffered_output rule, so no filed claim is
invalidated. Notes the asymmetry honestly: the hosted-tool half is pure
disclosure, while the history half additionally qualifies a 6.1 MUST and
leans on 12.1's incremental-mediation exception as its precedent.
Raised from a review comment on microsoft/agent-framework#7564; write-up
invited by @MohammadHaroonAbuomar in that thread.
Context providers can register additional tools during run preparation,
after agent_startup has been emitted, so tools_registered is inherently
a run-start snapshot and can be a partial view of the tools eventually
offered to the model.
- Emit the spec's optional pre_model_call tools field ({name,
description?}) from the per-call effective ChatOptions.Tools — the
completed set for each call, including provider-added tools.
- Document tools_registered as the run-start snapshot on the agent
seam (dynamic registrations surface per call and are bracketed by
the tool seam when invoked).
- Probe-confirm enforcement completeness for provider-added tools:
they flow through the guarded pipeline's tool-wrapping stage, emit
pre/post_tool_call, and a pre_tool_call deny blocks their
invocation exactly like constructor-registered tools.
Two new tests (bracketing + audit projections, deny-blocks); 86
total, green.
Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Comment threaddotnet/Directory.Packages.props Outdated
- Input projection returns (payload, content, role) as one typed
result so the emission site never re-reads payload properties by
name: the both-fields-exist invariant holds by construction. (The
previous reads were fail-closed even hypothetically — JsonObject's
indexer yields null, and a null content is rejected by the SDK's
envelope validation — but reading back what we just produced was
needlessly fragile-looking.)
- Reject UseProvidedChatClientAsIs on the factory: it signals a fully
custom, do-not-touch client stack, which is incompatible with a
factory whose job is to decorate the supplied client and rely on
the agent's default pipeline above the chat seam. Honoring it would
silently change where (and whether) the seams sit.
- Log swallowed agent_shutdown emission failures (logger resolved the
same way the agent resolves its own: services, then the chat
client, then null) so incomplete session trails are trackable;
OutOfMemoryException stays unswallowed. The swallow remains
correct: the run's own outcome is already propagating and the
trail closure is best-effort by contract.
89th test: UseProvidedChatClientAsIs rejection.
Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
…tity
Per review:
- Title per suggestion: 'Microsoft Agent Framework - Responsible AI
Agent-Hooks Protocol Support'; description names the protocol
precisely (AGENT-HOOKS-0.1, maintained by the Responsible AI
project at github.com/responsibleai/agent-hooks) so the package
reads as protocol support, not a MAF-owned feature; tags aligned.
- Drop the [Experimental] attributes: per repo convention the
attribute gates unstable surface inside released packages
(Harness, core), while pre-release packages (Valkey and Mcp at
alpha, Mem0 and LocalCodeAct at preview) carry none — the version
suffix is the maturity signal.
- Drop the describing comment on the central package version entry.
Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Per review: one type per file under Support/ (mock client, guards,
recording providers, helpers), matching the src-side convention; pure
mechanical split, flat namespace.
Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationUsage: [Issues, PRs], Target: documentation in the code base and learn docs.NETUsage: [Issues, PRs], Target: .Net

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: .NET: agent-hooks first-class integration (Python parity)

5 participants

@MohammadHaroonAbuomar@joslat@rogerbarreto@westey-m