From a87d8370997460125a7ed0813ac3d923eba9820d Mon Sep 17 00:00:00 2001
From: MohammadHaroonAbuomar
<40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Date: Sat, 25 Jul 2026 01:30:11 +0000
Subject: [PATCH 1/5] .Net: Add Microsoft.SemanticKernel.AgentHooks extension
Adapter that makes a Semantic Kernel host emit AGENT-HOOKS-0.1
interception points (https://github.com/responsibleai/agent-hooks) and
honour interceptor verdicts. Function invocations are bracketed as
pre_tool_call/post_tool_call with transform write-back to kernel
arguments and function results; prompt rendering emits pre_model_call
with rendered-prompt substitution; auto function invocation emits
post_model_call once per model response. Interceptors resolve from the
service collection; composition profile, enforcement mode, approval
resolver, and record sink are configurable via AddAgentHooks options.
Block verdicts surface as AgentHooksInterceptionBlockedException, so
enforcement fails closed through the filter pipeline.
---
dotnet/Directory.Packages.props | 1 +
dotnet/SK-dotnet.slnx | 4 +
.../Extensions/AgentHooks/AgentHooks.csproj | 29 ++
.../Extensions/AgentHooks/AgentHooksFilter.cs | 269 ++++++++++++++++++
.../AgentHooks/AgentHooksOptions.cs | 43 +++
.../AgentHooksServiceCollectionExtensions.cs | 34 +++
6 files changed, 380 insertions(+)
create mode 100644 dotnet/src/Extensions/AgentHooks/AgentHooks.csproj
create mode 100644 dotnet/src/Extensions/AgentHooks/AgentHooksFilter.cs
create mode 100644 dotnet/src/Extensions/AgentHooks/AgentHooksOptions.cs
create mode 100644 dotnet/src/Extensions/AgentHooks/AgentHooksServiceCollectionExtensions.cs
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 4165274cba62..34f63914584e 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/AgentHooks.csproj b/dotnet/src/Extensions/AgentHooks/AgentHooks.csproj
new file mode 100644
index 000000000000..ec00aa58cb88
--- /dev/null
+++ b/dotnet/src/Extensions/AgentHooks/AgentHooks.csproj
@@ -0,0 +1,29 @@
+
+
+
+
+ Microsoft.SemanticKernel.AgentHooks
+ $(AssemblyName)
+ net10.0;net8.0
+ $(NoWarn)
+ false
+
+
+
+
+
+
+
+ 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..558370e9e444
--- /dev/null
+++ b/dotnet/src/Extensions/AgentHooks/AgentHooksFilter.cs
@@ -0,0 +1,269 @@
+// 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.
+///
+public sealed class AgentHooksFilter :
+ IFunctionInvocationFilter, IPromptRenderFilter, IAutoFunctionInvocationFilter
+{
+ private readonly AgentHooksOptions _options;
+ private readonly IInterceptor[] _interceptors;
+ private readonly ConditionalWeakTable _sessions = [];
+
+ 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 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();
+ await this.EmitAsync(session, session.Builder.AgentStartup(tools)).ConfigureAwait(false);
+ }
+ 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();
+ }
+ }
+
+ await next(context).ConfigureAwait(false);
+
+ 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