Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
<PackageVersion Include="protobuf-net.Reflection" Version="3.2.52" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
<PackageVersion Include="Fluid.Core" Version="2.31.0" />
<PackageVersion Include="ResponsibleAI.AgentHooks" Version="0.1.0-alpha.4" />
<!-- Memory stores -->
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.54.0" />
<PackageVersion Include="Pgvector" Version="0.3.2" />
Expand Down
4 changes: 4 additions & 0 deletions dotnet/SK-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@
<Project Path="src/Experimental/Process.UnitTests/Process.UnitTests.csproj" />
<Project Path="src/Experimental/Process.Utilities.UnitTests/Process.Utilities.UnitTests.csproj" />
</Folder>
<Folder Name="/src/Extensions/">
<Project Path="src/Extensions/AgentHooks.UnitTests/AgentHooks.UnitTests.csproj" />
<Project Path="src/Extensions/AgentHooks/AgentHooks.csproj" />
</Folder>
<Folder Name="/src/functions/">
<Project Path="src/Functions/Functions.Grpc/Functions.Grpc.csproj" />
<Project Path="src/Functions/Functions.OpenApi.Extensions/Functions.OpenApi.Extensions.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>SemanticKernel.Extensions.AgentHooks.UnitTests</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<TargetFramework>net10.0</TargetFramework>
<IsTestProject>true</IsTestProject>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);CA2007,CS1591,VSTHRD111</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AgentHooks\AgentHooks.csproj" />
</ItemGroup>
</Project>
176 changes: 176 additions & 0 deletions dotnet/src/Extensions/AgentHooks.UnitTests/AgentHooksFilterTests.cs
Original file line number Diff line number Diff line change
@@ -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<AgentHooksOptions>? configure = null,
List<InterceptionRecord>? 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<AgentContext, Verdict> script) : IInterceptor
{
public ValueTask<Verdict> InterceptAsync(AgentContext context, CancellationToken ct = default) =>
ValueTask.FromResult(script(context));
}

private static string PointOf(AgentContext ctx) =>
ctx.Json["interception_point"]!.GetValue<string>();

[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<AgentHooksInterceptionBlockedException>(
() => 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<string>());
}

[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<string>());
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<AgentHooksInterceptionBlockedException>(
() => kernel.InvokeAsync(function));
}

[Fact]
public async Task RecordsAreEmittedForStartupAndToolBracketsAsync()
{
var records = new List<InterceptionRecord>();
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<ApprovalResolution> 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<AgentHooksInterceptionBlockedException>(
() => kernel.InvokeAsync(function));

// §6.1a: the session processes nothing after a blocked startup.
var second = await Assert.ThrowsAsync<AgentHooksInterceptionBlockedException>(
() => kernel.InvokeAsync(function));
Assert.Null(second.Record);
}

[Fact]
public async Task ToolErrorStillEmitsPostToolCallAsync()
{
var records = new List<InterceptionRecord>();
var kernel = BuildKernel(
new ScriptedInterceptor(_ => Verdict.Allow),
records: records);
var function = KernelFunctionFactory.CreateFromMethod(
new Func<string>(() => throw new InvalidOperationException("boom")), "Probe");

await Assert.ThrowsAsync<InvalidOperationException>(() => kernel.InvokeAsync(function));

var post = Assert.Single(records, r => r.InterceptionPoint == InterceptionPoint.PostToolCall);
Assert.True(post.Verdict.Decision == Decision.Allow);
}
}
30 changes: 30 additions & 0 deletions dotnet/src/Extensions/AgentHooks/AgentHooks.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!-- THIS PROPERTY GROUP MUST COME FIRST -->
<AssemblyName>Microsoft.SemanticKernel.AgentHooks</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<TargetFrameworks>net10.0;net8.0</TargetFrameworks>
<NoWarn>$(NoWarn)</NoWarn>
<EnablePackageValidation>false</EnablePackageValidation>
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>

<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<Import Project="$(RepoRoot)/dotnet/src/InternalUtilities/src/InternalUtilities.props" />

<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Semantic Kernel - Agent Hooks Interception</Title>
<Description>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).</Description>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="SemanticKernel.Extensions.AgentHooks.UnitTests" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\SemanticKernel.Core\SemanticKernel.Core.csproj" />
<PackageReference Include="ResponsibleAI.AgentHooks" />
</ItemGroup>
</Project>
Loading
Loading