diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 4165274cba62..c894fae8f4cc 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -186,6 +186,7 @@
+
diff --git a/dotnet/SK-dotnet.slnx b/dotnet/SK-dotnet.slnx
index 966fbcb713a3..7d8b954d77a0 100644
--- a/dotnet/SK-dotnet.slnx
+++ b/dotnet/SK-dotnet.slnx
@@ -140,6 +140,10 @@
+
+
+
+
diff --git a/dotnet/src/Extensions/AgentHooks.UnitTests/AgentHooks.UnitTests.csproj b/dotnet/src/Extensions/AgentHooks.UnitTests/AgentHooks.UnitTests.csproj
new file mode 100644
index 000000000000..985997e4df39
--- /dev/null
+++ b/dotnet/src/Extensions/AgentHooks.UnitTests/AgentHooks.UnitTests.csproj
@@ -0,0 +1,27 @@
+
+
+ SemanticKernel.Extensions.AgentHooks.UnitTests
+ $(AssemblyName)
+ net10.0
+ true
+ enable
+ disable
+ false
+ $(NoWarn);CA2007,CS1591,VSTHRD111
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
diff --git a/dotnet/src/Extensions/AgentHooks.UnitTests/AgentHooksFilterTests.cs b/dotnet/src/Extensions/AgentHooks.UnitTests/AgentHooksFilterTests.cs
new file mode 100644
index 000000000000..47f0b3e7c693
--- /dev/null
+++ b/dotnet/src/Extensions/AgentHooks.UnitTests/AgentHooksFilterTests.cs
@@ -0,0 +1,176 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AgentHooks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.SemanticKernel;
+using Microsoft.SemanticKernel.AgentHooks;
+using Xunit;
+
+namespace SemanticKernel.Extensions.AgentHooks.UnitTests;
+
+public sealed class AgentHooksFilterTests
+{
+ private static Kernel BuildKernel(
+ IInterceptor interceptor,
+ Action? configure = null,
+ List? records = null)
+ {
+ var builder = Kernel.CreateBuilder();
+ builder.Services.AddSingleton(interceptor);
+ builder.Services.AddAgentHooks(o =>
+ {
+ o.AgentId = "test-agent";
+ if (records is not null)
+ {
+ o.RecordSink = records.Add;
+ }
+ configure?.Invoke(o);
+ });
+ return builder.Build();
+ }
+
+ private sealed class ScriptedInterceptor(Func script) : IInterceptor
+ {
+ public ValueTask InterceptAsync(AgentContext context, CancellationToken ct = default) =>
+ ValueTask.FromResult(script(context));
+ }
+
+ private static string PointOf(AgentContext ctx) =>
+ ctx.Json["interception_point"]!.GetValue();
+
+ [Fact]
+ public async Task DenyAtPreToolCallBlocksFunctionInvocationAsync()
+ {
+ bool invoked = false;
+ var kernel = BuildKernel(new ScriptedInterceptor(ctx =>
+ PointOf(ctx) == "pre_tool_call"
+ ? new Verdict(Decision.Deny, "blocked_by_test")
+ : Verdict.Allow));
+ var function = KernelFunctionFactory.CreateFromMethod(
+ () => { invoked = true; return "ran"; }, "Probe");
+
+ var ex = await Assert.ThrowsAsync(
+ () => kernel.InvokeAsync(function));
+
+ Assert.False(invoked);
+ Assert.Equal("blocked_by_test", ex.Record!.Verdict.Reason);
+ Assert.Equal(InterceptionPoint.PreToolCall, ex.Record!.InterceptionPoint);
+ }
+
+ [Fact]
+ public async Task TransformAtPreToolCallRewritesArgumentsAsync()
+ {
+ string? observed = null;
+ var kernel = BuildKernel(new ScriptedInterceptor(ctx =>
+ PointOf(ctx) == "pre_tool_call"
+ ? new Verdict(Decision.Transform,
+ Transform: new Transform("$target.text", JsonValue.Create("redacted")))
+ : Verdict.Allow));
+ var function = KernelFunctionFactory.CreateFromMethod(
+ (string text) => { observed = text; return text; }, "Echo");
+
+ var result = await kernel.InvokeAsync(function, new() { ["text"] = "secret" });
+
+ Assert.Equal("redacted", observed);
+ Assert.Equal("redacted", result.GetValue());
+ }
+
+ [Fact]
+ public async Task LiftableDenyWithApprovalProceedsAsync()
+ {
+ var resolver = new ApproveAllResolver();
+ var kernel = BuildKernel(
+ new ScriptedInterceptor(ctx =>
+ PointOf(ctx) == "pre_tool_call"
+ ? Verdict.Escalate("needs_review")
+ : Verdict.Allow),
+ o => o.ApprovalResolver = resolver);
+ var function = KernelFunctionFactory.CreateFromMethod(() => "ran", "Probe");
+
+ var result = await kernel.InvokeAsync(function);
+
+ Assert.Equal("ran", result.GetValue());
+ Assert.True(resolver.Consulted);
+ }
+
+ [Fact]
+ public async Task LiftableDenyWithoutResolverBlocksAsync()
+ {
+ var kernel = BuildKernel(new ScriptedInterceptor(ctx =>
+ PointOf(ctx) == "pre_tool_call"
+ ? Verdict.Escalate("needs_review")
+ : Verdict.Allow));
+ var function = KernelFunctionFactory.CreateFromMethod(() => "ran", "Probe");
+
+ await Assert.ThrowsAsync(
+ () => kernel.InvokeAsync(function));
+ }
+
+ [Fact]
+ public async Task RecordsAreEmittedForStartupAndToolBracketsAsync()
+ {
+ var records = new List();
+ var kernel = BuildKernel(
+ new ScriptedInterceptor(_ => Verdict.Allow), records: records);
+ var function = KernelFunctionFactory.CreateFromMethod(() => "ran", "Probe");
+
+ await kernel.InvokeAsync(function);
+
+ Assert.Equal(3, records.Count);
+ Assert.Equal(InterceptionPoint.AgentStartup, records[0].InterceptionPoint);
+ Assert.Equal(InterceptionPoint.PreToolCall, records[1].InterceptionPoint);
+ Assert.Equal(InterceptionPoint.PostToolCall, records[2].InterceptionPoint);
+ Assert.All(records, r => Assert.Equal("sequential/first_deny", r.Composition.Profile.ToWireName()));
+ }
+
+ private sealed class ApproveAllResolver : IApprovalResolver
+ {
+ public bool Consulted;
+
+ public ValueTask ResolveAsync(ApprovalRequest request, CancellationToken ct = default)
+ {
+ this.Consulted = true;
+ return ValueTask.FromResult(new ApprovalResolution(
+ ApprovalOutcome.Approve, request.ContextIdentity, Verdict.Allow));
+ }
+ }
+
+ [Fact]
+ public async Task DeniedStartupPoisonsTheSessionAsync()
+ {
+ var kernel = BuildKernel(new ScriptedInterceptor(ctx =>
+ PointOf(ctx) == "agent_startup"
+ ? Verdict.Deny("startup_denied")
+ : Verdict.Allow));
+ var function = KernelFunctionFactory.CreateFromMethod(() => "ran", "Probe");
+
+ await Assert.ThrowsAsync(
+ () => kernel.InvokeAsync(function));
+
+ // §6.1a: the session processes nothing after a blocked startup.
+ var second = await Assert.ThrowsAsync(
+ () => kernel.InvokeAsync(function));
+ Assert.Null(second.Record);
+ }
+
+ [Fact]
+ public async Task ToolErrorStillEmitsPostToolCallAsync()
+ {
+ var records = new List();
+ var kernel = BuildKernel(
+ new ScriptedInterceptor(_ => Verdict.Allow),
+ records: records);
+ var function = KernelFunctionFactory.CreateFromMethod(
+ new Func(() => throw new InvalidOperationException("boom")), "Probe");
+
+ await Assert.ThrowsAsync(() => kernel.InvokeAsync(function));
+
+ var post = Assert.Single(records, r => r.InterceptionPoint == InterceptionPoint.PostToolCall);
+ Assert.True(post.Verdict.Decision == Decision.Allow);
+ }
+}
diff --git a/dotnet/src/Extensions/AgentHooks/AgentHooks.csproj b/dotnet/src/Extensions/AgentHooks/AgentHooks.csproj
new file mode 100644
index 000000000000..289f0aae4da2
--- /dev/null
+++ b/dotnet/src/Extensions/AgentHooks/AgentHooks.csproj
@@ -0,0 +1,30 @@
+
+
+
+
+ Microsoft.SemanticKernel.AgentHooks
+ $(AssemblyName)
+ net10.0;net8.0
+ $(NoWarn)
+ false
+ alpha
+
+
+
+
+
+
+
+ Semantic Kernel - Agent Hooks Interception
+ Semantic Kernel host adapter for the AGENT-HOOKS-0.1 control contract: emits interception points from kernel filters and honours interceptor verdicts (https://github.com/responsibleai/agent-hooks).
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Extensions/AgentHooks/AgentHooksFilter.cs b/dotnet/src/Extensions/AgentHooks/AgentHooksFilter.cs
new file mode 100644
index 000000000000..13a3dfb60c3e
--- /dev/null
+++ b/dotnet/src/Extensions/AgentHooks/AgentHooksFilter.cs
@@ -0,0 +1,313 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using AgentHooks;
+
+namespace Microsoft.SemanticKernel.AgentHooks;
+
+///
+/// Semantic Kernel filter that emits AGENT-HOOKS-0.1 interception points
+/// (https://github.com/responsibleai/agent-hooks) and honours interceptor
+/// verdicts.
+///
+///
+/// Mapping: brackets each kernel
+/// function invocation as pre_tool_call/post_tool_call;
+/// emits pre_model_call over the
+/// rendered prompt before it is submitted;
+/// emits post_model_call once per model response that carries function
+/// calls. agent_startup is emitted lazily on the first interception for
+/// a kernel. The input, output, and agent_shutdown points
+/// have no kernel-level seam and are not emitted by this adapter.
+/// A block verdict surfaces as ;
+/// filter exceptions propagate, so enforcement fails closed. A
+/// post_tool_call transform substitutes a JSON-typed function result,
+/// which may differ from the original CLR result type.
+///
+public sealed class AgentHooksFilter :
+ IFunctionInvocationFilter, IPromptRenderFilter, IAutoFunctionInvocationFilter
+{
+ private readonly AgentHooksOptions _options;
+ private readonly IInterceptor[] _interceptors;
+ private readonly ConditionalWeakTable _sessions = [];
+
+ /// Creates the filter with the adapter options and the DI-resolved interceptors.
+ public AgentHooksFilter(AgentHooksOptions options, IEnumerable interceptors)
+ {
+ this._options = options;
+ this._interceptors = interceptors.ToArray();
+ }
+
+ /// Per-kernel emitter state: one agent-hooks session per .
+ private sealed class KernelSession
+ {
+ public required InterceptionEmitter Emitter { get; init; }
+ public required AgentContextBuilder Builder { get; init; }
+ public bool StartupEmitted;
+ public bool StartupBlocked;
+ public readonly object StartupLock = new();
+ }
+
+ private KernelSession GetSession(Kernel kernel) =>
+ this._sessions.GetValue(kernel, k =>
+ {
+ var emitter = new InterceptionEmitter(
+ this._options.Mode, this._options.ApprovalResolver, this._options.InterceptorTimeout);
+ emitter.SetComposition(this._options.Composition);
+ if (this._options.RecordSink is not null)
+ {
+ emitter.SetRecordSink(this._options.RecordSink);
+ }
+ foreach (var interceptor in this._interceptors)
+ {
+ emitter.Register(interceptor, interceptor.GetType().Name);
+ }
+ var sessionId = this._options.SessionIdProvider?.Invoke(k) ?? Guid.NewGuid().ToString("N");
+ return new KernelSession
+ {
+ Emitter = emitter,
+ Builder = new AgentContextBuilder(this._options.AgentId, "semantic-kernel", sessionId),
+ };
+ });
+
+ private async ValueTask EnsureStartupAsync(Kernel kernel)
+ {
+ var session = this.GetSession(kernel);
+ bool emitStartup = false;
+ lock (session.StartupLock)
+ {
+ if (!session.StartupEmitted)
+ {
+ session.StartupEmitted = true;
+ emitStartup = true;
+ }
+ }
+ if (emitStartup)
+ {
+ var tools = kernel.Plugins
+ .SelectMany(p => p.Select(f => $"{p.Name}.{f.Name}"))
+ .ToArray();
+ try
+ {
+ await this.EmitAsync(session, session.Builder.AgentStartup(tools)).ConfigureAwait(false);
+ }
+ catch (AgentHooksInterceptionBlockedException)
+ {
+ session.StartupBlocked = true;
+ throw;
+ }
+ }
+
+ // §6.1a: a blocked agent_startup means the session processes nothing.
+ if (session.StartupBlocked)
+ {
+ throw new AgentHooksInterceptionBlockedException();
+ }
+
+ return session;
+ }
+
+ private async ValueTask EmitAsync(KernelSession session, AgentContext ctx)
+ {
+ try
+ {
+ return await session.Emitter.EmitAsync(ctx).ConfigureAwait(false);
+ }
+ catch (InterceptionBlockedException ex)
+ {
+ throw new AgentHooksInterceptionBlockedException(ex.Result);
+ }
+ }
+
+ ///
+ public async Task OnFunctionInvocationAsync(
+ FunctionInvocationContext context, Func next)
+ {
+ var session = await this.EnsureStartupAsync(context.Kernel).ConfigureAwait(false);
+ var callId = Guid.NewGuid().ToString("N");
+ var name = context.Function.PluginName is { } plugin
+ ? $"{plugin}.{context.Function.Name}"
+ : context.Function.Name;
+
+ var args = ToJsonObject(context.Arguments);
+ var pre = await this.EmitAsync(session, session.Builder.PreToolCall(callId, name, args))
+ .ConfigureAwait(false);
+
+ // A transform verdict rewrote tool_call.args (§5.2): write the
+ // effective target back into the kernel arguments before invocation.
+ if (pre.Target is JsonObject effective && !JsonNode.DeepEquals(effective, args))
+ {
+ foreach (var (key, value) in effective)
+ {
+ context.Arguments[key] = value?.DeepClone();
+ }
+ }
+
+ try
+ {
+ await next(context).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not AgentHooksInterceptionBlockedException)
+ {
+ // The invocation completed with an error: the contract still
+ // brackets it with post_tool_call (tool_result.is_error = true).
+ var errorArgs = pre.Target as JsonObject ?? args;
+ var error = (JsonNode)(ex.GetType().Name);
+ await this.EmitAsync(
+ session,
+ session.Builder.PostToolCall(callId, name, errorArgs, error, isError: true))
+ .ConfigureAwait(false);
+ throw;
+ }
+
+ var resultValue = SerializeResult(context.Result);
+ var effectiveArgs = pre.Target as JsonObject ?? args;
+ var post = await this.EmitAsync(
+ session,
+ session.Builder.PostToolCall(callId, name, effectiveArgs, resultValue, isError: false))
+ .ConfigureAwait(false);
+
+ // A transform at post_tool_call rewrote tool_result.value (§4.3):
+ // substitute the function result the caller observes.
+ if (post.Target is { } transformed && !JsonNode.DeepEquals(transformed, resultValue))
+ {
+ context.Result = new FunctionResult(context.Result, transformed.DeepClone());
+ }
+ }
+
+ ///
+ public async Task OnPromptRenderAsync(
+ PromptRenderContext context, Func next)
+ {
+ await next(context).ConfigureAwait(false);
+ if (context.RenderedPrompt is not { } prompt)
+ {
+ return;
+ }
+
+ var session = await this.EnsureStartupAsync(context.Kernel).ConfigureAwait(false);
+ var messages = new JsonArray(new JsonObject
+ {
+ ["role"] = "user",
+ ["content"] = prompt,
+ });
+ var modelId = context.ExecutionSettings?.ModelId ?? "unknown";
+ var outcome = await this.EmitAsync(session, session.Builder.PreModelCall(modelId, messages))
+ .ConfigureAwait(false);
+
+ // A transform rewrote messages (§4.3): substitute the rendered
+ // prompt that will be submitted to the model.
+ if (outcome.Target is JsonArray rewritten &&
+ rewritten.Count == 1 &&
+ rewritten[0] is JsonObject m &&
+ m["content"]?.GetValue() is { } newPrompt &&
+ !string.Equals(newPrompt, prompt, StringComparison.Ordinal))
+ {
+ context.RenderedPrompt = newPrompt;
+ }
+ }
+
+ ///
+ public async Task OnAutoFunctionInvocationAsync(
+ AutoFunctionInvocationContext context, Func next)
+ {
+ // Emit post_model_call once per model response: the first function of
+ // the first request carries the response that scheduled the calls.
+ if (context.FunctionSequenceIndex == 0)
+ {
+ var session = await this.EnsureStartupAsync(context.Kernel).ConfigureAwait(false);
+ var toolCalls = new JsonArray();
+ foreach (var item in context.ChatMessageContent.Items.OfType())
+ {
+ toolCalls.Add(new JsonObject
+ {
+ ["id"] = item.Id ?? string.Empty,
+ ["name"] = item.PluginName is { } p ? $"{p}.{item.FunctionName}" : item.FunctionName,
+ ["args"] = item.Arguments is { } fa ? ToJsonObject(fa) : new JsonObject(),
+ });
+ }
+ var modelId = context.ExecutionSettings?.ModelId ?? "unknown";
+ await this.EmitAsync(
+ session,
+ session.Builder.PostModelCall(
+ modelId,
+ context.ChatMessageContent.Content,
+ toolCalls,
+ finishReason: "tool_calls"))
+ .ConfigureAwait(false);
+ }
+
+ await next(context).ConfigureAwait(false);
+ }
+
+ private static JsonObject ToJsonObject(IDictionary arguments)
+ {
+ var json = new JsonObject();
+ foreach (var (key, value) in arguments)
+ {
+ json[key] = value switch
+ {
+ null => null,
+ JsonNode node => node.DeepClone(),
+ _ => SerializeValue(value),
+ };
+ }
+ return json;
+ }
+
+ private static JsonNode? SerializeValue(object value)
+ {
+ try
+ {
+ return JsonSerializer.SerializeToNode(value);
+ }
+ catch (NotSupportedException)
+ {
+ return JsonValue.Create(value.ToString());
+ }
+ }
+
+ private static JsonNode? SerializeResult(FunctionResult result) =>
+ result.GetValue