Spensara Spensara Knowledge Base & Documentation Repository

Repository Docs & Knowledge Base

Consolidated Spensara documentation repository (722 Markdown & architecture documents in ../repository)
Built Word Documents (.docx): Spensara Overview (.docx) Capability Assessment (.docx) Brink POS Ingestion Analysis (.docx) The Borg Ingestion Architecture (.docx)

Implementation Specs (157)

Detailed engineering and feature implementation specifications.
implementation-specs/specs/AnomalyDetection/TASK2.2.md 13,766 bytes · Updated Oct 23, 2026 10:44 AM EDT
Raw File

TASK 2.2: Implement AnomalyDetectorAgent with LLM + MCP Integration

Summary

Implement the anomaly detection agent that uses LLM reasoning with MCP tools to detect anomalies. The agent is fully generic - it receives a MonitorSpec (plain text instructions) and MCP tools (database access), and relies on the UseFunctionInvocation() middleware to handle the agentic tool-calling loop automatically.

Dependencies

  • TASK 1.4 (McpToolProvider for MCP tools)
  • TASK 2.1 (IAnomalyDetectorAgent interface, result types)

Architecture

MonitorSpec.SystemPrompt  ──►  LLM (Claude via Bedrock)
                                    │
                      UseFunctionInvocation() middleware
                        handles tool loop automatically
                                    │
                                    ├── calls mysql_query (MCP tool) ──► MySQL DB
                                    ├── calls mysql_query again...
                                    ├── reasons about results...
                                    └── calls emit_conclusion ──► AnomalyDetectionResult

How UseFunctionInvocation() Works

The FunctionInvokingChatClient middleware (added via UseFunctionInvocation() on the IChatClient pipeline) intercepts GetResponseAsync calls and:

  1. Sends the request to the inner chat client (LLM)
  2. If the LLM response contains tool call requests (FunctionCallContent), the middleware automatically invokes the corresponding AITool functions
  3. Appends the tool results to the conversation and sends it back to the LLM
  4. Repeats until the LLM produces a response with no tool calls (or max iterations reached)
  5. Returns the final ChatResponse with the full conversation history

This means the agent code does NOT need a manual for loop - a single GetResponseAsync call handles the entire multi-turn tool-calling conversation.

Agent Flow

  1. System prompt from MonitorSpec + user message with analysis time
  2. Single GetResponseAsync call with all tools (MCP + emit_conclusion)
  3. Middleware handles the entire loop: LLM → tool calls → tool results → LLM → ...
  4. When LLM calls emit_conclusion, the tool captures the structured result
  5. When LLM stops calling tools, GetResponseAsync returns
  6. Agent checks if emit_conclusion was called and returns the result

Files to Create

1. backend/Kds.Ai.AnomalyDetector/Agents/ConclusionHolder.cs

A simple mutable wrapper to capture the emit_conclusion tool result. This avoids the ref closure bug (closures cannot capture ref parameters).

csharp
namespace Kds.Ai.AnomalyDetector.Agents;

/// <summary>
/// Mutable holder for the agent's conclusion, captured by the emit_conclusion tool closure.
/// </summary>
internal sealed class ConclusionHolder
{
    public AnomalyDetectionResult? Result { get; set; }
}

2. backend/Kds.Ai.AnomalyDetector/Agents/AnomalyDetectorAgent.cs

csharp
using System.Text.Json;
using Kds.Ai.AnomalyDetector.Mcp;
using Kds.Ai.Data.Entities;
using Kds.Ai.Data.Enums;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;

namespace Kds.Ai.AnomalyDetector.Agents;

public sealed class AnomalyDetectorAgent : IAnomalyDetectorAgent
{
    private readonly IChatClient _chatClient;
    private readonly McpToolProvider _mcpToolProvider;
    private readonly ILogger<AnomalyDetectorAgent> _logger;

    public AnomalyDetectorAgent(
        IChatClient chatClient,
        McpToolProvider mcpToolProvider,
        ILogger<AnomalyDetectorAgent> logger)
    {
        _chatClient = chatClient;
        _mcpToolProvider = mcpToolProvider;
        _logger = logger;
    }

    public async Task<AnomalyDetectionResult> RunDetectionAsync(
        MonitorSpec spec,
        DateTimeOffset analysisTime,
        CancellationToken ct = default)
    {
        _logger.LogInformation(
            "Starting anomaly detection for monitor '{MonitorName}' at {AnalysisTime}",
            spec.Name, analysisTime);

        try
        {
            // 1. Get MCP tools (mysql_query etc.)
            var mcpTools = await _mcpToolProvider.GetToolsAsync(ct);

            // 2. Create the emit_conclusion tool for structured output
            var holder = new ConclusionHolder();
            var emitConclusionTool = CreateEmitConclusionTool(holder);

            // 3. Combine all tools
            var allTools = mcpTools.Concat([emitConclusionTool]).ToList();

            // 4. Build conversation
            var messages = new List<ChatMessage>
            {
                new(ChatRole.System, spec.SystemPrompt),
                new(ChatRole.User, $"Run analysis. Current time: {analysisTime:yyyy-MM-dd HH:mm:ss} UTC")
            };

            // 5. Single call - UseFunctionInvocation() middleware handles the agentic loop
            var response = await _chatClient.GetResponseAsync(
                messages, new ChatOptions { Tools = allTools }, ct);

            // 6. Extract token usage from the response
            var totalTokens = 0;
            if (response.Usage is not null)
                totalTokens = (response.Usage.InputTokenCount ?? 0) +
                              (response.Usage.OutputTokenCount ?? 0);

            // 7. Build reasoning trace from conversation history
            var reasoning = BuildReasoningTrace(response);

            // 8. Check if emit_conclusion was called
            if (holder.Result is not null)
            {
                return holder.Result with
                {
                    Reasoning = reasoning,
                    TotalTokens = totalTokens
                };
            }

            // Agent finished without calling emit_conclusion
            _logger.LogWarning(
                "Agent for '{MonitorName}' finished without calling emit_conclusion",
                spec.Name);

            return new AnomalyDetectionResult
            {
                AnomalyDetected = false,
                Reasoning = reasoning,
                TotalTokens = totalTokens,
                Warnings = ["Agent did not call emit_conclusion"]
            };
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Anomaly detection failed for monitor '{MonitorName}'", spec.Name);
            return AnomalyDetectionResult.Failed(ex.Message);
        }
    }

    private static AITool CreateEmitConclusionTool(ConclusionHolder holder)
    {
        return AIFunctionFactory.Create(
            (bool anomalyDetected, string? severity, string summary,
             string detailedAnalysis, string? findingsJson) =>
            {
                EventSeverity? parsedSeverity = null;
                if (!string.IsNullOrEmpty(severity) &&
                    Enum.TryParse<EventSeverity>(severity, true, out var sev))
                    parsedSeverity = sev;

                var findings = ParseFindings(findingsJson);

                holder.Result = new AnomalyDetectionResult
                {
                    AnomalyDetected = anomalyDetected,
                    Severity = parsedSeverity,
                    Summary = summary,
                    DetailedAnalysis = detailedAnalysis,
                    Findings = findings
                };

                return anomalyDetected
                    ? $"Conclusion recorded: {severity} severity - {summary}"
                    : $"Conclusion recorded: No anomaly - {summary}";
            },
            name: "emit_conclusion",
            description: "Report your final analysis conclusion with findings");
    }

    private static string BuildReasoningTrace(ChatResponse response)
    {
        var lines = new List<string>();

        foreach (var message in response.Messages)
        {
            if (message.Role == ChatRole.Assistant)
            {
                // Capture text content
                var text = message.Text;
                if (!string.IsNullOrWhiteSpace(text))
                    lines.Add($"[Assistant] {text}");

                // Capture tool calls
                foreach (var call in message.Contents.OfType<FunctionCallContent>())
                    lines.Add($"[Tool Call] {call.Name}({TruncateArgs(call.Arguments)})");
            }
            else if (message.Role == ChatRole.Tool)
            {
                // Capture tool results (truncated)
                foreach (var result in message.Contents.OfType<FunctionResultContent>())
                {
                    var resultText = result.Result?.ToString() ?? "";
                    var truncated = resultText.Length > 500
                        ? resultText[..500] + "..."
                        : resultText;
                    lines.Add($"[Tool Result] {result.CallId}: {truncated}");
                }
            }
        }

        return string.Join("\n", lines);
    }

    private static List<AnomalyFinding> ParseFindings(string? findingsJson)
    {
        if (string.IsNullOrEmpty(findingsJson)) return [];
        try
        {
            return JsonSerializer.Deserialize<List<AnomalyFinding>>(findingsJson) ?? [];
        }
        catch
        {
            return [];
        }
    }

    private static string TruncateArgs(IDictionary<string, object?>? args)
    {
        if (args is null) return "{}";
        var json = JsonSerializer.Serialize(args);
        return json.Length > 200 ? json[..200] + "..." : json;
    }
}

Key Implementation Details

UseFunctionInvocation() Middleware (NOT in this file)

The UseFunctionInvocation() middleware is registered on the IChatClient pipeline in DI (see Files to Modify below and TASK 5.2 for full DI wiring). It wraps the inner chat client with FunctionInvokingChatClient which:

  • Automatically invokes tools when the LLM requests them
  • Feeds tool results back to the LLM
  • Loops until the LLM produces a final text response (no more tool calls)
  • Has a configurable MaximumIterationsPerRequest (default is sufficient; can be tuned if needed)

This means the agent itself is simple - one GetResponseAsync call does all the work.

ConclusionHolder Pattern

The emit_conclusion tool needs to capture a result that the agent can read after GetResponseAsync returns. Since closures cannot capture ref parameters, we use a simple mutable wrapper class (ConclusionHolder) that the closure writes to and the agent reads from.

emit_conclusion Tool

This is the only tool we define in code. It provides a structured way for the LLM to report results that map to AnomalyDetectionResult. The system prompt in MonitorSpec should instruct the agent to use this tool.

Reasoning Trace from ChatResponse

With UseFunctionInvocation(), the returned ChatResponse contains the full conversation history (all messages including assistant tool calls and tool results). BuildReasoningTrace extracts this into a human-readable log for debugging.

No Domain Logic in Code

  • No prep-time specific code
  • No query builders
  • No typed DTOs for query results
  • The LLM handles all data interpretation

Files to Modify

3. backend/Kds.Ai.AgentsCommon/DependencyInjectionExtensions.cs

Add UseFunctionInvocation() to the IChatClient pipeline. This must be added here (not in AnomalyDetector module) because AgentsCommon is where AddChatClient is called:

csharp
public static IHostApplicationBuilder AddAgentsCommonModule(this IHostApplicationBuilder builder)
{
    builder.AddOptions<AwsBedrockCredentials>();
    builder.Services.AddChatClient(provider =>
    {
        var settings = provider.GetRequiredService<IOptions<AwsBedrockCredentials>>();
        var accessKey = settings.Value.AccessKeyId;
        var secretKey = settings.Value.SecretAccessKey;
        var runtime = new AmazonBedrockRuntimeClient(accessKey, secretKey, RegionEndpoint.USWest2);
        var modelId = settings.Value.ModelId;
        var client = runtime.AsIChatClient(modelId);
        return client;
    })
    .UseFunctionInvocation();  // <-- ADD THIS: enables automatic tool calling for all agents

    return builder;
}

Note: AddChatClient returns a ChatClientBuilder, so UseFunctionInvocation() chains directly. This enables automatic tool invocation for any agent that injects IChatClient.

4. backend/Kds.Ai.AnomalyDetector/DependencyInjectionExtensions.cs

Register the agent:

csharp
builder.Services.AddScoped<IAnomalyDetectorAgent, AnomalyDetectorAgent>();

Acceptance Criteria

  • [ ] AnomalyDetectorAgent implements IAnomalyDetectorAgent
  • [ ] Agent receives MCP tools from McpToolProvider
  • [ ] Agent adds emit_conclusion tool for structured output
  • [ ] UseFunctionInvocation() middleware registered on IChatClient pipeline in AgentsCommon
  • [ ] Single GetResponseAsync call (no manual agentic loop)
  • [ ] ConclusionHolder wrapper used instead of ref parameter
  • [ ] Token usage extracted from response
  • [ ] Reasoning trace built from full ChatResponse.Messages conversation history
  • [ ] Error handling prevents crashes
  • [ ] No domain-specific (prep-time, KDSDev) code
  • [ ] Solution builds without errors

Notes

  • The FunctionInvokingChatClient has a default MaximumIterationsPerRequest that should be sufficient. If agents hit the limit, it can be configured via the configure callback on UseFunctionInvocation().
  • ChatResponse.Messages contains the full multi-turn conversation after UseFunctionInvocation() resolves all tool calls. This is how we build the reasoning trace.
  • ChatResponse.Usage aggregates token usage across all iterations handled by the middleware.
  • MonitorSpec.SystemPrompt should instruct the agent to call emit_conclusion when done.