// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI; /// /// Specifies how matches tool names. /// public enum ToolCalledMode { /// All specified tools must have been called. All, /// At least one of the specified tools must have been called. Any, } /// /// Built-in check functions for common evaluation patterns. /// public static class EvalChecks { /// /// Creates a check that verifies the response contains all specified keywords. /// /// Keywords that must appear in the response. /// An delegate. public static EvalCheck KeywordCheck(params string[] keywords) { return KeywordCheck(caseSensitive: false, keywords); } /// /// Creates a check that verifies the response contains all specified keywords. /// /// Whether the comparison is case-sensitive. /// Keywords that must appear in the response. /// An delegate. public static EvalCheck KeywordCheck(bool caseSensitive, params string[] keywords) { return (EvalItem item) => { var comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; var missing = keywords .Where(kw => !item.Response.Contains(kw, comparison)) .ToList(); var passed = missing.Count == 0; var reason = passed ? $"All keywords found: {string.Join(", ", keywords)}" : $"Missing keywords: {string.Join(", ", missing)}"; return new EvalCheckResult(passed, reason, "keyword_check"); }; } /// /// Creates a check that verifies specific tools were called in the conversation. /// All specified tools must have been called. /// /// Tool names that must appear in the conversation. /// An delegate. public static EvalCheck ToolCalledCheck(params string[] toolNames) { return ToolCalledCheck(ToolCalledMode.All, toolNames); } /// /// Creates a check that verifies specific tools were called in the conversation. /// /// Whether or of the specified tools must be called. /// Tool names to check for. /// An delegate. public static EvalCheck ToolCalledCheck(ToolCalledMode mode, params string[] toolNames) { return (EvalItem item) => { var calledTools = GetCalledTools(item); if (mode == ToolCalledMode.Any) { var found = toolNames.Where(t => calledTools.Contains(t)).ToList(); var passed = found.Count > 0; var reason = passed ? $"Called: {string.Join(", ", found)}" : $"None of expected tools called: {string.Join(", ", toolNames)}"; return new EvalCheckResult(passed, reason, "tool_called_check"); } var missing = toolNames.Where(t => !calledTools.Contains(t)).ToList(); var allPassed = missing.Count == 0; var allReason = allPassed ? $"All tools called: {string.Join(", ", toolNames)}" : $"Missing tool calls: {string.Join(", ", missing)}"; return new EvalCheckResult(allPassed, allReason, "tool_called_check"); }; } /// /// A check that verifies at least one tool was called in the conversation. /// /// An delegate. public static EvalCheck ToolCallsPresent() { return (EvalItem item) => { var calledTools = GetCalledTools(item); var passed = calledTools.Count > 0; var reason = passed ? $"Tools called: {string.Join(", ", calledTools)}" : "No tool calls found in conversation"; return new EvalCheckResult(passed, reason, "tool_calls_present"); }; } /// /// A check that verifies expected tool calls match on name and optionally arguments. /// /// /// /// For each expected tool call, finds matching calls in the conversation by name. /// If is provided, checks that the actual /// arguments contain all expected key-value pairs (subset match — extra actual arguments are OK). /// /// If no expected tool calls are set on the item, the check passes. /// /// An delegate. public static EvalCheck ToolCallArgsMatch() { return (EvalItem item) => { var expected = item.ExpectedToolCalls; if (expected is null || expected.Count == 0) { return new EvalCheckResult(true, "No expected tool calls specified.", "tool_call_args_match"); } var actualCalls = GetCalledToolsWithArgs(item); int matched = 0; var details = new List(); foreach (var exp in expected) { var matching = actualCalls.Where(c => string.Equals(c.Name, exp.Name, StringComparison.OrdinalIgnoreCase)).ToList(); if (matching.Count == 0) { details.Add($" {exp.Name}: not called"); continue; } if (exp.Arguments is null) { matched++; details.Add($" {exp.Name}: called (args not checked)"); continue; } // Subset match — all expected keys present with expected values bool found = false; foreach (var call in matching) { if (call.Arguments is not null && exp.Arguments.All(kvp => call.Arguments.TryGetValue(kvp.Key, out var actual) && Equals(actual, kvp.Value))) { found = true; break; } } if (found) { matched++; details.Add($" {exp.Name}: args match"); } else { details.Add($" {exp.Name}: args mismatch"); } } var passed = matched == expected.Count; var reason = $"Tool call args match: {matched}/{expected.Count}\n{string.Join("\n", details)}"; return new EvalCheckResult(passed, reason, "tool_call_args_match"); }; } /// /// Creates a check that verifies the response is non-empty and meets a minimum length. /// /// Minimum response length (default 1). /// An delegate. public static EvalCheck NonEmpty(int minLength = 1) { return (EvalItem item) => { var trimmed = item.Response.Trim(); var passed = trimmed.Length >= minLength; var reason = passed ? $"Response length {trimmed.Length} meets minimum {minLength}" : $"Response length {trimmed.Length} is below minimum {minLength}"; return new EvalCheckResult(passed, reason, "non_empty"); }; } /// /// Creates a check that verifies the response contains the expected output text. /// /// Whether the comparison is case-sensitive (default false). /// An delegate. public static EvalCheck ContainsExpected(bool caseSensitive = false) { return (EvalItem item) => { if (string.IsNullOrEmpty(item.ExpectedOutput)) { return new EvalCheckResult(false, "ExpectedOutput is not set; check cannot be applied.", "contains_expected"); } var comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; var passed = item.Response.Contains(item.ExpectedOutput, comparison); var reason = passed ? $"Response contains expected output: \"{item.ExpectedOutput}\"" : $"Response does not contain expected output: \"{item.ExpectedOutput}\""; return new EvalCheckResult(passed, reason, "contains_expected"); }; } /// /// A check that verifies the conversation contains at least one image /// ( or with an image media type). /// /// An delegate. public static EvalCheck HasImageContent() { return (EvalItem item) => { var passed = item.HasImageContent; var reason = passed ? "Conversation contains image content" : "No image content found in conversation"; return new EvalCheckResult(passed, reason, "has_image_content"); }; } private static HashSet GetCalledTools(EvalItem item) { var calledTools = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var message in item.Conversation) { foreach (var content in message.Contents) { if (content is FunctionCallContent functionCall) { calledTools.Add(functionCall.Name); } } } return calledTools; } private static List<(string Name, IReadOnlyDictionary? Arguments)> GetCalledToolsWithArgs(EvalItem item) { var calls = new List<(string Name, IReadOnlyDictionary? Arguments)>(); foreach (var message in item.Conversation) { foreach (var content in message.Contents) { if (content is FunctionCallContent functionCall) { IDictionary? rawArgs = functionCall.Arguments; IReadOnlyDictionary? args = null; if (rawArgs is not null) { var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kvp in rawArgs) { if (kvp.Value is not null) { // Normalize JsonElement values to their .NET equivalents for comparison dict[kvp.Key] = kvp.Value is JsonElement je ? UnwrapJsonElement(je) : kvp.Value; } } args = dict; } calls.Add((functionCall.Name, args)); } } } return calls; } private static object UnwrapJsonElement(JsonElement element) { return element.ValueKind switch { JsonValueKind.String => element.GetString()!, JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, _ => element.ToString(), }; } }