Chapter 19: Filters and Hooks - Intercepting Agent Thoughts
Theoretical Foundations
In the architecture of intelligent agents, the execution of a function is not a monolithic event but a sequence of moments: a thought, a decision, an action, and a reflection. While the core of Microsoft Semantic Kernel (SK) orchestrates these moments, the ability to intercept, inspect, and modify them is what transforms a rigid script into a resilient, observable, and secure system. This capability is delivered through Filters and Hooks, mechanisms that allow developers to inject custom logic into the lifecycle of function invocations.
To understand the necessity of these mechanisms, we must look back at the foundational concepts established in Book 7, Chapter 15: The Planner and the Kernel. There, we discussed how the Kernel acts as the central nervous system, routing requests to appropriate plugins and managing execution context. However, that chapter focused primarily on the flow of execution—how a plan is generated and run. It treated the execution of a function as an atomic unit: input goes in, output comes out. Filters and Hooks break open that atomic unit, exposing the internal machinery of the agent's thought process.
The Conceptual Model: The Assembly Line Analogy
Imagine an advanced manufacturing facility (the Kernel) producing a complex product (the AI Response). Raw materials (User Input) enter the facility and are processed by various specialized machines (Plugins/Functions).
In a naive setup, you simply feed materials into Machine A, which outputs a component for Machine B. If a defect occurs, you might not know until the final product fails quality control. This is analogous to a kernel without filters—execution happens, and you only see the final result.
Pre-Execution Filters (The Input Inspector): Before materials enter Machine A, a sensor scans them. If the materials are contaminated (malicious input) or incorrect (invalid format), the inspector rejects them immediately or sanitizes them. This prevents damage to the machine and ensures the process starts correctly. 2. Post-Execution Filters (The Output Verifier): As components leave Machine A, a second sensor checks the output. Is it within tolerance? Does it contain hazardous material? If the component is flawed, it can be reworked or discarded before it reaches Machine B. 3. Exception Filters (The Emergency Stop): If Machine A jams or overheats (throws an exception), a specialized mechanism catches the error, logs the incident, and decides whether to halt the line, retry the operation, or bypass the machine entirely.
In Semantic Kernel, these "sensors" and "mechanisms" are implemented as Function Filters. They are middleware that wraps around every function invocation, providing a unified interface to intercept the flow of data and control.
The Lifecycle of a Function Invocation
To implement effective interception, we must understand the precise lifecycle of a function call within the Kernel. This lifecycle is a state machine that transitions through specific points, each offering a hook for intervention.
The lifecycle can be visualized as follows:
1. The Pre-Execution Phase (Input Filtering)
Before the actual C# method or prompt template is executed, the Kernel constructs an IFunctionInvocationContext. This context contains the Function metadata, the Arguments (input parameters), and the Kernel instance.
Why is this critical? In AI applications, inputs are often unstructured text from users. Without validation, a user could inject a prompt that causes the model to hallucinate, leak system instructions, or execute unintended logic. Furthermore, in complex agentic patterns, a planner might generate arguments that are technically valid but logically dangerous (e.g., passing a database query string directly into a text summarization function).
Validate Arguments: Ensure data types and ranges are correct.
* Sanitize Inputs: Remove sensitive data (PII) or malicious characters before they reach the LLM or backend system.
* Augment Context: Inject additional system messages or context variables dynamically based on the input.
* Short-circuit Execution: If an input is deemed unsafe, the filter can set the context.Result directly, bypassing the function execution entirely.
2. The Execution Phase
This is the "black box" where the actual work happens. It could be a native C# method (a plugin) or a semantic function (calling an LLM). While we cannot easily intercept the internal steps of an LLM during inference, the execution phase in SK is where the function is invoked. The filter acts as the wrapper around this invocation.
3. The Post-Execution Phase (Output Filtering)
Once the function returns (or the LLM generates a response), the result is placed back into the IFunctionInvocationContext. At this moment, the output is visible to the filter before it is returned to the caller (which might be another function in a chain or the final user).
Why is this critical? Content Safety: Check for hate speech, self-harm, or sexual content. * Formatting Enforcement: Ensure the output matches a required JSON schema or XML structure. * Data Masking: Redact any sensitive information that the LLM might have inadvertently generated or reproduced from its training data.
4. Exception Handling
If the function execution throws an exception, the standard C# exception propagation is intercepted by the Kernel's filter chain. This allows for centralized error logging, retry logic (e.g., retrying an LLM call on rate limit errors), or fallback mechanisms.
The Implementation Interface: IFunctionFilter
In modern C# (using features like record types and nullable reference types), Semantic Kernel defines a clean interface for these interceptors. While the specific API names might evolve, the conceptual interface for a function filter looks like this:
using System.Threading.Tasks;
using Microsoft.SemanticKernel;
// Conceptual representation of the filter interface
public interface IFunctionFilter
{
/// <summary>
/// Invoked before the function is executed.
/// </summary>
Task OnFunctionInvocationAsync(FunctionInvocationContext context);
/// <summary>
/// Invoked after the function is executed (or if an exception occurs).
/// </summary>
Task OnFunctionInvokedAsync(FunctionInvocationContext context);
}
The FunctionInvocationContext is the heart of the operation. It is a mutable object passed by reference through the pipeline.
// Conceptual representation of the context object
public class FunctionInvocationContext
{
public Kernel Kernel { get; init; }
public KernelFunction Function { get; init; }
public KernelArguments Arguments { get; set; } // Mutable!
public KernelResult? Result { get; set; } // Mutable!
public Exception? Exception { get; set; } // Populated on failure
public CancellationToken CancellationToken { get; init; }
}
Architectural Implications: The Chain of Responsibility
Pre-Execution: Filters are called in the order they were registered (First-In-First-Served). The output of one filter's OnFunctionInvocationAsync is the input for the next. This allows for layered concerns: a logging filter might run first, followed by a validation filter, followed by a caching filter.
2. Post-Execution: Filters are called in reverse order (Last-In-First-Out). This is similar to the try-finally block or the Dispose pattern in C#. The filter that wrapped the execution closest to the actual function call gets to process the result first, then unwinds the stack.
Why does the order matter? Pre-Execution Order: Logger -> Validator -> Cache. * The Logger records the start time. * The Validator checks the input. * The Cache checks if the result exists. * If the Cache hits, it sets the result and effectively skips the actual function execution. * Post-Execution Order: Cache -> Validator -> Logger. * If the Cache missed and the function executed, the Cache might want to store the result. * The Validator might want to verify the result format. * The Logger records the end time and duration.
This "onion" layering ensures that the outermost concerns (logging) wrap the innermost concerns (caching), maintaining logical consistency.
Why This Matters for AI Engineering
The theoretical power of Filters and Hooks lies in the separation of concerns. In AI engineering, the "business logic" is often fuzzy and probabilistic (the LLM's reasoning), while the "system logic" is deterministic and strict (security, data privacy, latency).
Without filters, developers are forced to clutter their plugin methods with boilerplate code:
// BAD PRACTICE: Mixing concerns
public async Task<string> SummarizeTextAsync(string text)
{
// 1. Logging
Console.WriteLine($"Starting summary for {text.Length} chars");
// 2. Validation
if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Text required");
if (text.Length > 10000) throw new ArgumentException("Text too long");
// 3. Caching (Pseudo-code)
var cached = await _cache.GetAsync(text);
if (cached != null) return cached;
// 4. Actual Logic
var result = await _llm.SummarizeAsync(text);
// 5. Sanitization
if (result.Contains("API Error")) throw new InvalidOperationException("LLM failed");
// 6. Caching Result
await _cache.SetAsync(text, result);
return result;
}
This code is brittle. If the caching logic changes, you must update every single plugin method.
With Filters (The Modern C# Approach): We leverage dependency injection and the Kernel's native capabilities to inject these behaviors globally.
// GOOD PRACTICE: Clean plugin
public class TextPlugin
{
[KernelFunction]
public async Task<string> SummarizeTextAsync(string text)
{
// Pure business logic
return await _llm.SummarizeAsync(text);
}
}
// Global Filter Registration
var kernel = new KernelBuilder()
.WithOpenAIChatCompletion("gpt-4", "key")
.Build();
// Injecting the filter (using modern DI patterns)
kernel.FunctionFilters.Add(new ValidationFilter());
kernel.FunctionFilters.Add(new CachingFilter());
kernel.FunctionFilters.Add(new LoggingFilter());
Advanced Pattern: Dynamic Intervention in Reasoning
Beyond simple validation, Filters allow for dynamic intervention in the agent's reasoning process. This is where the concept of "intercepting agent thoughts" becomes literal.
In a complex agentic workflow (e.g., a Planner that generates a sequence of functions), a filter can inspect the arguments generated by the LLM before execution.
The Planner generates a function call: EmailPlugin.Send("john@example.com", "Project is late").
2. The Intervention Filter: A filter intercepts this pre-execution. It checks the recipient. Is "john@example.com" actually the CEO? Is the content "Project is late" appropriate to send without review?
3. The Action: The filter modifies the arguments. It changes the recipient to a "Drafts" folder or appends a disclaimer: "Project is late (Draft - Pending Review)".
4. The Result: The agent thinks it sent the email, but the filter silently altered the behavior to enforce safety protocols.
This capability transforms the Kernel from a passive executor into an active guardian. It allows developers to enforce guardrails on LLM behavior without retraining the model or cluttering the prompts with excessive negative constraints.
Theoretical Foundations
The introduction of Filters and Hooks in Semantic Kernel represents a maturation of AI application architecture. It acknowledges that LLMs are powerful but unpredictable components that must be wrapped in robust software engineering patterns.
Enforce Security: Sanitize inputs and outputs globally. 2. Improve Reliability: Implement retry logic and fallback mechanisms. 3. Enhance Observability: Log every step of the agent's reasoning without modifying the agent's code. 4. Optimize Performance: Implement caching and result memoization transparently.
This theoretical framework sets the stage for the subsequent sections, where we will implement these patterns using modern C# features like IAsyncDisposable, records for immutable context snapshots, and Source Generators for high-performance filter registration.
Basic Code Example
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Functions;
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Tasks;
// Problem Context:
// Imagine a customer support chatbot that processes user complaints.
// Before sending a complaint to the backend system, we must ensure:
// 1. No sensitive data (like credit card numbers) is leaked.
// 2. The input is valid (not empty).
// 3. We log the interaction for auditing purposes.
//
// Instead of cluttering the business logic with validation and logging code,
// we use Semantic Kernel's Function Filters to intercept the execution flow.
// Step 1: Define a Custom Filter
// This class implements IFunctionPreFilter to intercept the function before it runs.
public class InputValidationFilter : IFunctionPreFilter
{
// This method is called before any kernel function executes.
public async ValueTask OnFunctionInvocationAsync(FunctionInvocationContext context, CancellationToken cancellationToken)
{
// Retrieve the input from the context arguments.
// We use 'context.Arguments' which is a dictionary of string key-value pairs.
if (context.Arguments.TryGetValue("complaint", out var complaintObj) && complaintObj is string complaint)
{
// Business Logic: Check for empty input.
if (string.IsNullOrWhiteSpace(complaint))
{
// We can modify the result directly to short-circuit the execution.
// Here, we return a user-friendly error message without calling the actual function.
context.Result = new FunctionResult(context.Function, "Error: Complaint cannot be empty.");
return; // Stop execution here.
}
// Business Logic: Check for sensitive data (e.g., a pattern looking like a credit card).
// In a real scenario, use regex or a dedicated PII detection service.
if (complaint.Contains("1234-5678-9012-3456"))
{
// Sanitize the input by replacing the sensitive part.
complaint = complaint.Replace("1234-5678-9012-3456", "[REDACTED]");
// Update the context arguments so the downstream function receives the clean data.
context.Arguments["complaint"] = complaint;
}
}
// We must call await context.ProceedAsync() to allow the actual function to run.
// If we returned early (like in the empty check), we skip this.
await context.ProceedAsync(cancellationToken);
}
// Priority determines the order of execution if multiple filters are registered.
// Lower numbers run first. We want validation to happen early.
public int Order => 0;
}
// Step 2: Define a Post-Execution Filter
// This class implements IFunctionPostFilter to intercept the result after the function runs.
public class TelemetryFilter : IFunctionPostFilter
{
public async ValueTask OnFunctionInvocationAsync(FunctionInvocationContext context, CancellationToken cancellationToken)
{
// This runs AFTER the actual function logic has completed.
// We access the result via context.Result.
// Log the interaction details (simulated with Console.WriteLine).
Console.WriteLine($"[Telemetry] Function '{context.Function.Name}' executed.");
Console.WriteLine($"[Telemetry] Result Status: {(context.Result.IsSuccess ? "Success" : "Failure")}");
// We can inspect or modify the result here if needed (e.g., masking output).
// For this example, we just pass it through.
await context.ProceedAsync(cancellationToken);
}
// We want telemetry to run after validation and the main function.
public int Order => 100;
}
// Step 3: Define the Kernel Function (The "Tool" the agent uses)
public class SupportSystem
{
[KernelFunction("process_complaint")]
[Description("Processes a customer complaint and logs it to the database.")]
public string ProcessComplaint(string complaint)
{
// This represents the core business logic.
// It assumes the input is already validated and sanitized.
return $"Complaint processed successfully: '{complaint}'";
}
}
// Step 4: Main Execution Block
class Program
{
static async Task Main(string[] args)
{
// Initialize the Kernel builder.
var builder = Kernel.CreateBuilder();
// Add a simple chat completion service (required by the kernel, though we aren't using AI prompts here).
// Note: In a real scenario, you would add AzureOpenAI or OpenAI here.
// For this purely functional example, we rely on direct function calls.
builder.Services.AddLogging(c => c.AddConsole().SetMinimumLevel(LogLevel.Warning));
// Build the kernel.
var kernel = builder.Build();
// Register our custom filters.
// Semantic Kernel automatically detects classes implementing the filter interfaces.
kernel.FunctionInvocationFilters.Add(new InputValidationFilter());
kernel.FunctionInvocationFilters.Add(new TelemetryFilter());
// Register the plugin containing our kernel function.
kernel.Plugins.AddFromType<SupportSystem>("Support");
// --- SCENARIO 1: Valid Input ---
Console.WriteLine("--- Test 1: Valid Complaint ---");
var result1 = await kernel.InvokeAsync("Support", "process_complaint", new KernelArguments { ["complaint"] = "My package arrived late." });
Console.WriteLine($"Output: {result1}\n");
// --- SCENARIO 2: Empty Input (Should be caught by Pre-Filter) ---
Console.WriteLine("--- Test 2: Empty Complaint ---");
var result2 = await kernel.InvokeAsync("Support", "process_complaint", new KernelArguments { ["complaint"] = "" });
Console.WriteLine($"Output: {result2}\n");
// --- SCENARIO 3: Sensitive Data (Should be sanitized by Pre-Filter) ---
Console.WriteLine("--- Test 3: Complaint with Sensitive Data ---");
var result3 = await kernel.InvokeAsync("Support", "process_complaint", new KernelArguments { ["complaint"] = "I paid with card 1234-5678-9012-3456 and it failed." });
Console.WriteLine($"Output: {result3}\n");
}
}
Detailed Explanation
1. The Problem: Cross-Cutting Concerns
In enterprise software, business logic (processing a complaint) is often buried under "cross-cutting concerns" like validation, logging, security checks, and telemetry. If we write this logic inside the ProcessComplaint method, the code becomes hard to read and maintain. Furthermore, if we have 50 different functions, we would have to duplicate this logic 50 times.
The Solution: We use the Interceptor Pattern via Semantic Kernel's Filters. We separate the "What" (business logic) from the "How" (validation/logging).
2. Code Breakdown
A. The InputValidationFilter (Pre-Execution Logic)
``csharp
public class InputValidationFilter : IFunctionPreFilter
**IFunctionPreFilter**: This interface tells Semantic Kernel to execute this class *before* the target kernel function runs.
* **OnFunctionInvocationAsync**: This is the entry point.
* **context.Arguments**: A dictionary containing the inputs passed to the function (e.g., the user's complaint string).
* **Validation Logic**: We check if the complaint is empty. If it is, we setcontext.Resultmanually. This is crucial: by setting the result, we tell the kernel "I have the answer, don't run the actual function."
* **Sanitization Logic**: We check for sensitive patterns. If found, we modifycontext.Arguments["complaint"]. This modified value is what the downstream function will see.
* **context.ProceedAsync()`**: This tells the kernel to continue to the next filter or the actual function. If we don't call this (and didn't set a result), the execution hangs.
B. The TelemetryFilter (Post-Execution Logic)
``csharp
public class TelemetryFilter : IFunctionPostFilter
**IFunctionPostFilter**: This interface executes *after* the target function finishes.
* **Accessing Results**: We accesscontext.Resultto read the output of the business logic. We can log it, modify it, or even throw an exception based on the output.
* **OrderProperty**: Filters run in a sequence. TheInputValidationFilterhasOrder = 0, andTelemetryFilterhasOrder = 100`. This ensures validation happens first, then the function, then telemetry.
kernel.FunctionInvocationFilters.Add(...): We manually register our filter classes. Semantic Kernel scans them and executes them based on their interface type (IFunctionPreFilter vs IFunctionPostFilter) and Order.
* kernel.InvokeAsync(...): This triggers the entire pipeline. Even though we call Support.process_complaint, the kernel first routes the request through InputValidationFilter, then the function, then TelemetryFilter.
3. Execution Flow Visualization
The following diagram illustrates the flow of data and control during a function invocation with filters.
4. Step-by-Step Execution Analysis
Kernel Invocation: User calls kernel.InvokeAsync.
2. Pre-Filter Entry: InputValidationFilter.OnFunctionInvocationAsync starts.
3. Validation: Checks if string is empty. It is not.
4. Sanitization: Checks for "1234...". Not found.
5. Proceed: Calls context.ProceedAsync().
6. Function Execution: SupportSystem.ProcessComplaint runs. It receives the original string.
7. Post-Filter Entry: TelemetryFilter.OnFunctionInvocationAsync starts.
8. Logging: Prints "[Telemetry] Function 'process_complaint' executed."
9. Proceed: Calls context.ProceedAsync().
10. Return: The final result is returned to the user.
Kernel Invocation: User calls kernel.InvokeAsync.
2. Pre-Filter Entry: InputValidationFilter.OnFunctionInvocationAsync starts.
3. Validation: Checks if string is empty. It is empty.
4. Short-Circuit: Sets context.Result to an error message.
5. Return: The filter returns immediately. ProceedAsync is NOT called.
6. Function Skipped: SupportSystem.ProcessComplaint is never executed.
7. Post-Filter Skipped: TelemetryFilter is never executed (because the flow stopped in the pre-filter).
8. Return: The error message "Error: Complaint cannot be empty." is returned to the user.
Kernel Invocation: User calls kernel.InvokeAsync.
2. Pre-Filter Entry: InputValidationFilter.OnFunctionInvocationAsync starts.
3. Validation: Not empty.
4. Sanitization: Pattern matches. context.Arguments["complaint"] is updated to "...[REDACTED]...".
5. Proceed: Calls context.ProceedAsync().
6. Function Execution: SupportSystem.ProcessComplaint runs. It receives the sanitized string containing "[REDACTED]".
7. Post-Filter Entry: TelemetryFilter.OnFunctionInvocationAsync starts.
8. Logging: Prints logs.
9. Return: The sanitized result is returned.
Common Pitfalls
1. Forgetting to Call context.ProceedAsync()
Correction: Always ensure ProceedAsync is called unless you explicitly intend to short-circuit the execution by setting context.Result.
2. Modifying Arguments Incorrectly
Correction: Use pattern matching for safe casting. If the function signature expects a complex type (like a Class), modifying the string representation in Arguments might cause type mismatch errors later. Ensure type consistency.
3. Blocking Synchronous Calls in Filters
Correction: Always use await and async/await patterns. The filter interfaces return ValueTask, which is optimized for synchronous completion but supports async flows.
4. Exception Handling in Filters
Correction: Wrap your filter logic in try/catch blocks if you want the filter failure to not impact the core function execution, or let it bubble up if the validation failure is critical.
The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon
Loading knowledge check...
Code License: All code examples are released under the MIT License. Github repo.
Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.
All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.