Evaluation of AI Agents (Preview)
The evaluation tests check the behavior of the AI agents against the real models: they detect the regressions caused by
a change of prompt, of skill, of model or of AI library version. They rely on the Microsoft.Extensions.AI.Evaluation
libraries and on the AIAgentEvaluation base class of the GroupeIsa.Neos.Application.XUnit package.
See Evaluation libraries for more information.
Configuration
An evaluation uses two kinds of models:
- the tested models: the models of the evaluated agents, configured as in the application (
AIsection of the cluster and its secrets); - the judge model: the model which evaluates the answers, configured in the
AI:Evaluationsection.
Every configuration key can be supplied by the cluster configuration, by the user secrets or by an environment variable,
using __ as the section separator (for example AI__Evaluation__TargetModel).
| Key | Description |
|---|---|
AI:Evaluation:Provider |
Provider of the judge model: OpenAI, AzureOpenAI or Anthropic. |
AI:Evaluation:ModelId |
Identifier of the judge model (for example gpt-6-luna). |
AI:Evaluation:ApiKey |
API key of the judge model. |
AI:Evaluation:ApiEndpoint |
Endpoint of the judge model, required for an alternative endpoint such as Azure OpenAI or Microsoft Foundry. |
AI:Evaluation:SupportsTemperature |
true when the judge model accepts a temperature. false by default, because the reasoning models reject it. |
AI:Evaluation:TargetModel |
Name of the tested model, among the models of the application. When set, it replaces the model of the agents. |
AI:Evaluation:StorageRootPath |
Folder where the evaluation results are stored (current directory by default). |
AI:Evaluation:ExecutionName |
Name of the execution in the report (date and time by default, build number in the pipeline). |
AI:Evaluation:EnableResponseCaching |
false disables the caching of the judge model responses. true by default, which avoids paying the same judgement twice locally. |
The evaluation project reads, in this order, the application settings of the cluster, the user secrets of the cluster,
the user secrets dedicated to the evaluations (GroupeIsa.Neos.AI.Evaluation) and the environment variables: the last
one wins. The dedicated user secrets are therefore the right place to configure the evaluations once for all the clusters
of a computer:
dotnet user-secrets set "AI:Evaluation:Provider" "AzureOpenAI" --id GroupeIsa.Neos.AI.Evaluation
dotnet user-secrets set "AI:Evaluation:ModelId" "gpt-6-luna" --id GroupeIsa.Neos.AI.Evaluation
dotnet user-secrets set "AI:Evaluation:ApiEndpoint" "[ApiEndpoint]" --id GroupeIsa.Neos.AI.Evaluation
dotnet user-secrets set "AI:Evaluation:ApiKey" "[ApiKey]" --id GroupeIsa.Neos.AI.Evaluation
The tested models can be configured the same way, for example AI:ApiEndpoint and AI:ApiKey for the models using the
default endpoint of the application.
Note
The internal agents of the framework (smart search, translations, input suggestions) read their model from a setting
such as AI:SmartSearchModel rather than from their prompt. To evaluate them with AI:Evaluation:TargetModel, the
Startup of the evaluation project applies the tested model to these settings (see the Northwind evaluation project,
which evaluates them with AzureGpt54, the gpt-5.4 model, when AI:Evaluation:TargetModel is not set).
Evaluators
The judge model evaluates the answers with the following evaluators, chosen with WithEvaluator:
| Evaluator | What is evaluated |
|---|---|
Groundedness |
The answer is grounded on the evaluation context given with WithContext. |
RelevanceTruthAndCompleteness |
The answer is relevant, true and complete regarding the question. |
Coherence |
The answer is coherent and well written. |
Fluency |
The answer is fluent. |
ToolCallAccuracy |
The functions (skills, tools) called by the agent are the right ones, with the right arguments. |
TaskAdherence |
The agent adheres to the task it was given, including the functions it called. |
IntentResolution |
The agent understood and resolved the intent of the user. |
The functions called by the agent and their results are given to the judge model with the answer, as well as the definitions of all the functions the agent could have called.
Warning
The Microsoft.Extensions.AI.Evaluation libraries are in preview, so these evaluators may change in future versions.
Create the evaluation project
In Neos Studio, in the AI agent view, the "Create Evaluation Project" button creates the projects/AI.AgentEvaluation
project of the cluster, or updates it, and adds a test class for the current agent. Each time you want to evaluate a new
agent, you add its class with this action.
The generated test class carries the [AIEvaluation("<AgentId>")] attribute and contains two example scenarios, skipped
until you replace their placeholders: a repeated evaluation by the judge model, and a question outside the scope of the
agent which must not call any function.
Note
A partial Startup class is created in the test project, to configure its services: you can add your own services or
replace some of them with a mock. The Startup.Generated.cs file is generated and must not be modified.
The generated services include the OpenAPI document of the application, built as in the application but outside of the web host: the skills creating or proposing data read the JSON schema of the entity views in this document, and can therefore be evaluated.
In the evaluation tests, the services are configured with their real implementation. Replace in the Startup class the
services that would write data or need an infrastructure the evaluation does not have, for example:
public void ConfigureServices(IServiceCollection services)
{
_ = InternalConfigureServices(services);
// The user interface does not exist: the interactions are only recorded as function calls.
Mock<IUIInteraction> uiInteractionMock = new();
services.AddSingleton(uiInteractionMock);
services.Replace(ServiceDescriptor.Scoped(sp => uiInteractionMock.Object));
// Without any authenticated user, the agents would not see any view: every function resource is allowed.
Mock<IFunctionResourceAuthorizer> functionResourceAuthorizerMock = new();
functionResourceAuthorizerMock
.Setup(s => s.IsAllowedAsync(It.IsAny<FunctionResourceType>(), It.IsAny<string>()))
.ReturnsAsync(true);
functionResourceAuthorizerMock
.Setup(s => s.IsAllowedAsync(It.IsAny<FunctionResourceType>(), It.IsAny<string>(), It.IsAny<FunctionOperation?>()))
.ReturnsAsync(true);
services.Replace(ServiceDescriptor.Scoped(sp => functionResourceAuthorizerMock.Object));
// The saves succeed without writing anything.
Mock<IUnitOfWork> unitOfWorkMock = new() { DefaultValue = DefaultValue.Mock };
unitOfWorkMock.Setup(u => u.SaveAsync(It.IsAny<CancellationToken>())).ReturnsAsync(Result.Ok());
services.Replace(ServiceDescriptor.Scoped(sp => unitOfWorkMock.Object));
}
The mocks are shared by the tests, which may run in parallel: set them up once in the Startup class, and check what the
agent did through its function calls rather than by verifying the mocks.
Write an evaluation test
Check an exact answer
When the answer can be checked without any judge model, use PromptAsync and assert the answer as in a classic unit
test. PromptAsync<TAgent, TResult> prompts the agent for a typed answer, as the application does with a JSON schema:
[Fact]
public async Task ShouldCreateTheDescribedImage()
{
AIEvaluationOptions options = new AIEvaluationOptions("A red circle on a white background").WithRepetitions(3, 2);
await PromptRepeatedlyAsync<ImageCreatorAgent, GeneratedSvgImage>(
options,
image =>
{
image.FileName.Should().MatchRegex("^[A-Z][A-Za-z0-9]*\\.svg$");
XElement.Parse(image.Content).Name.LocalName.Should().Be("svg");
},
TestContext.Current.CancellationToken);
}
Absorb the non-determinism of the model
The same question can get different answers. WithRepetitions(3, 2) runs a case up to three times and requires two
successes. PromptRepeatedlyAsync, EvaluateRepeatedlyAsync and RepeatAsync (for a service of the application) stop
as soon as the required number of successes is reached, or can no longer be reached, and fail the test with the reason of
each failed attempt.
List all the right answers rather than a single one: several translations or corrections can be right.
Check the functions called by the agent
FunctionCalls contains the functions (skills, tools) called during the last prompt, with their arguments, results and
errors, and PromptInvocations the prompts answered, sub-agents included, with their token usage:
[Fact]
public async Task ShouldNotOpenAnyView_WhenTheRequestIsNotANavigation()
{
AIEvaluationOptions options = new AIEvaluationOptions("What is the capital of Australia?").WithRepetitions(3, 2);
await PromptRepeatedlyAsync<NeosNavigateAgent>(
options,
_ => FunctionCalls.Should().NotContain(call => call.FunctionName == "OpenUIViewName"),
TestContext.Current.CancellationToken);
}
The test output lists the answer, each function call (and its error, if any) and the token usage of each model used by the agent and its sub-agents.
For each agent, write at least a nominal case, a case in French, a case outside the scope of the agent and, for the agents with skills, a case checking that nothing is saved without the confirmation of the user.
Evaluate a textual answer with the judge model
When the answer is free text, use EvaluateAsync or EvaluateRepeatedlyAsync with the evaluators you need:
AIEvaluationOptions options = new AIEvaluationOptions(
"In which UI view is the \"ShowChatbot\" method defined?",
"The \"ShowChatbot\" method is defined in the \"DesignerUI\" UI view.")
.WithEvaluator(AIEvaluator.Groundedness)
.WithRepetitions(3, 2);
await EvaluateRepeatedlyAsync<EntityExplorerAgent>(
options,
result =>
{
NumericMetric groundedness = result.Get<NumericMetric>(GroundednessEvaluator.GroundednessMetricName);
groundedness.Interpretation!.Failed.Should().BeFalse(groundedness.Reason);
},
TestContext.Current.CancellationToken);
The Groundedness evaluator needs an evaluation context: the information allowing to check the answer, not only an
expected answer. A test fails with an explicit message when the judge model cannot evaluate the answer (wrong endpoint,
unknown deployment...).
Compare OData filters
The agents building OData filters can give several filters with the same meaning. ODataFilterNormalizer.AreEquivalent
compares them by meaning: the order of the conditions, the parentheses, the names of the lambda variables, the side of
the literals and the simple negations do not matter.
ODataFilterNormalizer.AreEquivalent("not(toupper(City) ne 'LYON')", "'LYON' eq toupper(City)").Should().BeTrue();
Run the evaluation tests
Every evaluation test carries the Category=AIEvaluation trait, and the Agent=<AgentId> trait when its class carries
the [AIEvaluation("<AgentId>")] attribute. Run the tests of a single agent with a filter:
dotnet test projects/AI.AgentEvaluation/AI.AgentEvaluation.slnx --filter "Category=AIEvaluation&Agent=NeosNavigate"
Note
In a deep folder, such as a git worktree, run the built test assembly with the xUnit runner, which accepts the same
traits: dotnet exec AI.AgentEvaluation.dll -trait "Agent=NeosNavigate".
Read the evaluation report
The results of the judge model are stored in the AI:Evaluation:StorageRootPath folder. Install the dotnet tool once,
then generate the report after running the tests:
dotnet tool install --local Microsoft.Extensions.AI.Evaluation.Console --version 10.9.0
dotnet tool run aieval report --path [StorageRootPath] --output report.html
The report shows, for each scenario and iteration, the metrics, the reasons given by the judge model and its token usage.
Limitations
- The agents depending on real data or on a vector index are evaluated with mocked skills returning a fixed context.
Upgrade your test project
After upgrading Neos, upgrade the evaluation projects of your cluster by running the following command at its root. It
updates the version of the evaluation libraries, the project file and the Startup.Generated.cs file:
neos upgrade-ai-test
Generate the cluster as well (neos generate): the generated Startup.Generated.cs builds the OpenAPI document through
the Startup class of the cluster, which must be generated with the same version of Neos.