.NET: Declarative Agents (#1301)

* AgentFactory abstractions and ChatClient implementation

* Add a getitng started sample

* Update to latest M.B.OM

* Add some additional samples

* Work in progress

* Merge latest from main

* Start to add support for using different kinds of connections

* Remove IsSupported

* Remove IsSupported

* Refactor code to create clients to support DI

* Add some unit tests

* Update based on the latest code review feedback

* Add support for OOB tools when using persistent agent sdk

* Fix sample naming

* Fix error based on latest MEAI

* Update M.B.OM package to latest

* Update to the latest M.B.OM release

* Remove some obsolete helper methods

* Update to the latest M.B.OM version

* Fix broken unit test

* Update MCP sample

* Bump to latest M.B.OM release

* Update to latest M.B.OM release

* Update to latest M.B.OM release

* Switch to using ExternalModel

* Update to latest M.B.OM

* Resolve merge conflicts

* All tests pass

* All tests pass

* Start to clean up the code

* Start to clean up the code

* More clean up

* More clean up

* More clean up

* Fix apiType checks

* Run dotnet format

* Fix typo

* Address code review feedback

* Add all properties for MCP tool

* Address code review feedback

* Address code review feedback

* Fix merge

* Undo warnings

* Undo test change

* More copilot feedback

* Make class sealed

* Address additional core review feedback

---------

Co-authored-by: Mark Wallace <markwallace@microsoft.com>
This commit is contained in:
Mark Wallace
2025-11-11 11:39:20 +00:00
committed by GitHub
co-authored by Mark Wallace
parent 105dc82c39
commit aaa91954c5
75 changed files with 3980 additions and 3 deletions
@@ -0,0 +1,256 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
namespace Microsoft.Agents.AI.Declarative.UnitTests;
/// <summary>
/// Unit tests for <see cref="AgentBotElementYaml"/>
/// </summary>
public sealed class AgentBotElementYamlTests
{
[Theory]
[InlineData(PromptAgents.AgentWithEverything)]
[InlineData(PromptAgents.AgentWithApiKeyConnection)]
[InlineData(PromptAgents.AgentWithEnvironmentVariables)]
[InlineData(PromptAgents.AgentWithOutputSchema)]
[InlineData(PromptAgents.OpenAIChatAgent)]
[InlineData(PromptAgents.AgentWithCurrentModels)]
[InlineData(PromptAgents.AgentWithRemoteConnection)]
public void FromYaml_DoesNotThrow(string text)
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(text);
// Assert
Assert.NotNull(agent);
}
[Fact]
public void FromYaml_Properties()
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything);
// Assert
Assert.NotNull(agent);
Assert.Equal("AgentName", agent.Name);
Assert.Equal("Agent description", agent.Description);
Assert.Equal("You are a helpful assistant.", agent.Instructions?.ToTemplateString());
Assert.NotNull(agent.Model);
Assert.True(agent.Tools.Length > 0);
}
[Fact]
public void FromYaml_CurrentModels()
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithCurrentModels);
// Assert
Assert.NotNull(agent);
Assert.NotNull(agent.Model);
Assert.Equal("gpt-4o", agent.Model.ModelNameHint);
Assert.NotNull(agent.Model.Options);
Assert.Equal(0.7f, (float?)agent.Model.Options?.Temperature?.LiteralValue);
Assert.Equal(0.9f, (float?)agent.Model.Options?.TopP?.LiteralValue);
// Assert contents using extension methods
Assert.Equal(1024, agent.Model.Options?.MaxOutputTokens?.LiteralValue);
Assert.Equal(50, agent.Model.Options?.TopK?.LiteralValue);
Assert.Equal(0.7f, (float?)agent.Model.Options?.FrequencyPenalty?.LiteralValue);
Assert.Equal(0.7f, (float?)agent.Model.Options?.PresencePenalty?.LiteralValue);
Assert.Equal(42, agent.Model.Options?.Seed?.LiteralValue);
Assert.Equal(PromptAgents.s_stopSequences, agent.Model.Options?.StopSequences);
Assert.True(agent.Model.Options?.AllowMultipleToolCalls?.LiteralValue);
Assert.Equal(ChatToolMode.Auto, agent.Model.Options?.AsChatToolMode());
}
[Fact]
public void FromYaml_OutputSchema()
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithOutputSchema);
// Assert
Assert.NotNull(agent);
Assert.NotNull(agent.OutputType);
var responseFormat = agent.OutputType.AsChatResponseFormat() as ChatResponseFormatJson;
Assert.NotNull(responseFormat);
Assert.NotNull(responseFormat.Schema);
}
[Fact]
public void FromYaml_CodeInterpreter()
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything);
// Assert
Assert.NotNull(agent);
var tools = agent.Tools;
var codeInterpreterTools = tools.Where(t => t is CodeInterpreterTool).ToArray();
Assert.Single(codeInterpreterTools);
var codeInterpreterTool = codeInterpreterTools[0] as CodeInterpreterTool;
Assert.NotNull(codeInterpreterTool);
}
[Fact]
public void FromYaml_FunctionTool()
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything);
// Assert
Assert.NotNull(agent);
var tools = agent.Tools;
var functionTools = tools.Where(t => t is InvokeClientTaskAction).ToArray();
Assert.Single(functionTools);
var functionTool = functionTools[0] as InvokeClientTaskAction;
Assert.NotNull(functionTool);
Assert.Equal("GetWeather", functionTool.Name);
Assert.Equal("Get the weather for a given location.", functionTool.Description);
// TODO check schema
}
[Fact]
public void FromYaml_MCP()
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything);
// Assert
Assert.NotNull(agent);
var tools = agent.Tools;
var mcpTools = tools.Where(t => t is McpServerTool).ToArray();
Assert.Single(mcpTools);
var mcpTool = mcpTools[0] as McpServerTool;
Assert.NotNull(mcpTool);
Assert.Equal("PersonInfoTool", mcpTool.ServerName?.LiteralValue);
var connection = mcpTool.Connection as AnonymousConnection;
Assert.NotNull(connection);
Assert.Equal("https://my-mcp-endpoint.com/api", connection.Endpoint?.LiteralValue);
}
[Fact]
public void FromYaml_WebSearchTool()
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything);
// Assert
Assert.NotNull(agent);
var tools = agent.Tools;
var webSearchTools = tools.Where(t => t is WebSearchTool).ToArray();
Assert.Single(webSearchTools);
Assert.NotNull(webSearchTools[0] as WebSearchTool);
}
[Fact]
public void FromYaml_FileSearchTool()
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEverything);
// Assert
Assert.NotNull(agent);
var tools = agent.Tools;
var fileSearchTools = tools.Where(t => t is FileSearchTool).ToArray();
Assert.Single(fileSearchTools);
var fileSearchTool = fileSearchTools[0] as FileSearchTool;
Assert.NotNull(fileSearchTool);
// Verify vector store content property exists and has correct values
Assert.NotNull(fileSearchTool.VectorStoreIds);
Assert.Equal(3, fileSearchTool.VectorStoreIds.LiteralValue.Length);
Assert.Equal("1", fileSearchTool.VectorStoreIds.LiteralValue[0]);
Assert.Equal("2", fileSearchTool.VectorStoreIds.LiteralValue[1]);
Assert.Equal("3", fileSearchTool.VectorStoreIds.LiteralValue[2]);
}
[Fact]
public void FromYaml_ApiKeyConnection()
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithApiKeyConnection);
// Assert
Assert.NotNull(agent);
Assert.NotNull(agent.Model);
var model = agent.Model as CurrentModels;
Assert.NotNull(model);
Assert.NotNull(model.Connection);
Assert.IsType<ApiKeyConnection>(model.Connection);
var connection = model.Connection as ApiKeyConnection;
Assert.NotNull(connection);
Assert.Equal("https://my-azure-openai-endpoint.openai.azure.com/", connection.Endpoint?.LiteralValue);
Assert.Equal("my-api-key", connection.Key?.LiteralValue);
}
[Fact]
public void FromYaml_RemoteConnection()
{
// Arrange & Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithRemoteConnection);
// Assert
Assert.NotNull(agent);
Assert.NotNull(agent.Model);
var model = agent.Model as CurrentModels;
Assert.NotNull(model);
Assert.NotNull(model.Connection);
Assert.IsType<RemoteConnection>(model.Connection);
var connection = model.Connection as RemoteConnection;
Assert.NotNull(connection);
Assert.Equal("https://my-azure-openai-endpoint.openai.azure.com/", connection.Endpoint?.LiteralValue);
}
[Fact]
public void FromYaml_WithEnvironmentVariables()
{
// Arrange
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["OpenAIEndpoint"] = "endpoint",
["OpenAIModelId"] = "modelId",
["OpenAIApiKey"] = "apiKey"
})
.Build();
// Act
var agent = AgentBotElementYaml.FromYaml(PromptAgents.AgentWithEnvironmentVariables, configuration);
// Assert
Assert.NotNull(agent);
Assert.NotNull(agent.Model);
var model = agent.Model as CurrentModels;
Assert.NotNull(model);
Assert.NotNull(model.Connection);
Assert.IsType<ApiKeyConnection>(model.Connection);
//Assert.Equal("https://my-azure-openai-endpoint.openai.azure.com/", agent.Model.Connection.Endpoint?.LiteralValue);
//Assert.Equal("apiKey", connection.Key?.LiteralValue);
//Assert.Equal("modelId", model.Id);
}
/// <summary>
/// Represents information about a person, including their name, age, and occupation, matched to the JSON schema used in the agent.
/// </summary>
[Description("Information about a person including their name, age, and occupation")]
public class PersonInfo
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("age")]
public int? Age { get; set; }
[JsonPropertyName("occupation")]
public string? Occupation { get; set; }
}
}
@@ -0,0 +1,107 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Declarative.UnitTests.ChatClient;
/// <summary>
/// Unit tests for <see cref="ChatClientAgentFactory"/>.
/// </summary>
public sealed class ChatClientAgentFactoryTests
{
private readonly Mock<IChatClient> _mockChatClient;
public ChatClientAgentFactoryTests()
{
this._mockChatClient = new();
}
[Fact]
public async Task TryCreateAsync_WithChatClientInConstructor_CreatesAgentAsync()
{
// Arrange
var promptAgent = PromptAgents.CreateTestPromptAgent();
ChatClientAgentFactory factory = new(this._mockChatClient.Object);
// Act
AIAgent? agent = await factory.TryCreateAsync(promptAgent);
// Assert
Assert.NotNull(agent);
Assert.IsType<ChatClientAgent>(agent);
Assert.Equal("Test Agent", agent.Name);
Assert.Equal("Test Description", agent.Description);
}
[Fact]
public async Task TryCreateAsync_Creates_ChatClientAgentAsync()
{
// Arrange
var promptAgent = PromptAgents.CreateTestPromptAgent();
ChatClientAgentFactory factory = new(this._mockChatClient.Object);
// Act
AIAgent? agent = await factory.TryCreateAsync(promptAgent);
// Assert
Assert.NotNull(agent);
Assert.IsType<ChatClientAgent>(agent);
var chatClientAgent = agent as ChatClientAgent;
Assert.NotNull(chatClientAgent);
Assert.Equal("You are a helpful assistant.", chatClientAgent.Instructions);
Assert.NotNull(chatClientAgent.ChatClient);
Assert.NotNull(chatClientAgent.ChatOptions);
}
[Fact]
public async Task TryCreateAsync_Creates_ChatOptionsAsync()
{
// Arrange
var promptAgent = PromptAgents.CreateTestPromptAgent();
ChatClientAgentFactory factory = new(this._mockChatClient.Object);
// Act
AIAgent? agent = await factory.TryCreateAsync(promptAgent);
// Assert
Assert.NotNull(agent);
Assert.IsType<ChatClientAgent>(agent);
var chatClientAgent = agent as ChatClientAgent;
Assert.NotNull(chatClientAgent?.ChatOptions);
Assert.Equal("Provide detailed and accurate responses.", chatClientAgent?.ChatOptions?.Instructions);
Assert.Equal(0.7F, chatClientAgent?.ChatOptions?.Temperature);
Assert.Equal(0.7F, chatClientAgent?.ChatOptions?.FrequencyPenalty);
Assert.Equal(1024, chatClientAgent?.ChatOptions?.MaxOutputTokens);
Assert.Equal(0.9F, chatClientAgent?.ChatOptions?.TopP);
Assert.Equal(50, chatClientAgent?.ChatOptions?.TopK);
Assert.Equal(0.7F, chatClientAgent?.ChatOptions?.PresencePenalty);
Assert.Equal(42L, chatClientAgent?.ChatOptions?.Seed);
Assert.NotNull(chatClientAgent?.ChatOptions?.ResponseFormat);
Assert.Equal("gpt-4o", chatClientAgent?.ChatOptions?.ModelId);
Assert.Equal(["###", "END", "STOP"], chatClientAgent?.ChatOptions?.StopSequences);
Assert.True(chatClientAgent?.ChatOptions?.AllowMultipleToolCalls);
Assert.Equal(ChatToolMode.Auto, chatClientAgent?.ChatOptions?.ToolMode);
Assert.Equal("customValue", chatClientAgent?.ChatOptions?.AdditionalProperties?["customProperty"]);
}
[Fact]
public async Task TryCreateAsync_Creates_ToolsAsync()
{
// Arrange
var promptAgent = PromptAgents.CreateTestPromptAgent();
ChatClientAgentFactory factory = new(this._mockChatClient.Object);
// Act
AIAgent? agent = await factory.TryCreateAsync(promptAgent);
// Assert
Assert.NotNull(agent);
Assert.IsType<ChatClientAgent>(agent);
var chatClientAgent = agent as ChatClientAgent;
Assert.NotNull(chatClientAgent?.ChatOptions?.Tools);
var tools = chatClientAgent?.ChatOptions?.Tools;
Assert.Equal(5, tools?.Count);
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<NoWarn>$(NoWarn);IDE1006</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Declarative\Microsoft.Agents.AI.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Bot.ObjectModel" />
<PackageReference Include="Microsoft.Bot.ObjectModel.Json" />
<PackageReference Include="Microsoft.Bot.ObjectModel.PowerFx" />
</ItemGroup>
</Project>
@@ -0,0 +1,326 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Bot.ObjectModel;
namespace Microsoft.Agents.AI.Declarative.UnitTests;
internal static class PromptAgents
{
internal const string AgentWithEverything =
"""
kind: Prompt
name: AgentName
description: Agent description
instructions: You are a helpful assistant.
model:
id: gpt-4o
options:
temperature: 0.7
maxOutputTokens: 1024
topP: 0.9
topK: 50
frequencyPenalty: 0.0
presencePenalty: 0.0
seed: 42
responseFormat: text
stopSequences:
- "###"
- "END"
- "STOP"
allowMultipleToolCalls: true
tools:
- kind: codeInterpreter
inputs:
- kind: HostedFileContent
FileId: fileId123
- kind: function
name: GetWeather
description: Get the weather for a given location.
parameters:
- name: location
type: string
description: The city and state, e.g. San Francisco, CA
required: true
- name: unit
type: string
description: The unit of temperature. Possible values are 'celsius' and 'fahrenheit'.
required: false
enum:
- celsius
- fahrenheit
- kind: mcp
serverName: PersonInfoTool
serverDescription: Get information about a person.
connection:
kind: AnonymousConnection
endpoint: https://my-mcp-endpoint.com/api
allowedTools:
- "GetPersonInfo"
- "UpdatePersonInfo"
- "DeletePersonInfo"
approvalMode:
kind: HostedMcpServerToolRequireSpecificApprovalMode
AlwaysRequireApprovalToolNames:
- "UpdatePersonInfo"
- "DeletePersonInfo"
NeverRequireApprovalToolNames:
- "GetPersonInfo"
- kind: webSearch
name: WebSearchTool
description: Search the web for information.
- kind: fileSearch
name: FileSearchTool
description: Search files for information.
ranker: default
scoreThreshold: 0.5
maxResults: 5
maxContentLength: 2000
vectorStoreIds:
- 1
- 2
- 3
""";
internal const string AgentWithOutputSchema =
"""
kind: Prompt
name: Translation Assistant
description: A helpful assistant that translates text to a specified language.
model:
id: gpt-4o
options:
temperature: 0.9
topP: 0.95
instructions: You are a helpful assistant. You answer questions in {language}. You return your answers in a JSON format.
additionalInstructions: You must always respond in the specified language.
tools:
- kind: codeInterpreter
template:
format: PowerFx # Mustache is the other option
parser: None # Prompty and XML are the other options
inputSchema:
properties:
language: string
outputSchema:
properties:
language:
type: string
required: true
description: The language of the answer.
answer:
type: string
required: true
description: The answer text.
""";
internal const string AgentWithApiKeyConnection =
"""
kind: Prompt
name: AgentName
description: Agent description
instructions: You are a helpful assistant.
model:
id: gpt-4o
connection:
kind: ApiKey
endpoint: https://my-azure-openai-endpoint.openai.azure.com/
key: my-api-key
""";
internal const string AgentWithRemoteConnection =
"""
kind: Prompt
name: AgentName
description: Agent description
instructions: You are a helpful assistant.
model:
id: gpt-4o
connection:
kind: Remote
endpoint: https://my-azure-openai-endpoint.openai.azure.com/
""";
internal const string AgentWithEnvironmentVariables =
"""
kind: Prompt
name: AgentName
description: Agent description
instructions: You are a helpful assistant.
model:
id: =Env.OpenAIModelId
connection:
kind: apiKey
endpoint: =Env.OpenAIEndpoint
key: =Env.OpenAIApiKey
""";
internal const string OpenAIChatAgent =
"""
kind: Prompt
name: Assistant
description: Helpful assistant
instructions: You are a helpful assistant. You answer questions in the language specified by the user. You return your answers in a JSON format.
model:
id: =Env.OPENAI_MODEL
options:
temperature: 0.9
topP: 0.95
connection:
kind: apiKey
key: =Env.OPENAI_APIKEY
outputSchema:
properties:
language:
type: string
required: true
description: The language of the answer.
answer:
type: string
required: true
description: The answer text.
""";
internal const string AgentWithCurrentModels =
"""
kind: Prompt
name: AgentName
description: Agent description
instructions: You are a helpful assistant.
model:
id: gpt-4o
options:
temperature: 0.7
maxOutputTokens: 1024
topP: 0.9
topK: 50
frequencyPenalty: 0.7
presencePenalty: 0.7
seed: 42
responseFormat: text
stopSequences:
- "###"
- "END"
- "STOP"
allowMultipleToolCalls: true
chatToolMode: auto
""";
internal const string AgentWithCurrentModelsSnakeCase =
"""
kind: Prompt
name: AgentName
description: Agent description
instructions: You are a helpful assistant.
model:
id: gpt-4o
options:
temperature: 0.7
max_output_tokens: 1024
top_p: 0.9
top_k: 50
frequency_penalty: 0.7
presence_penalty: 0.7
seed: 42
response_format: text
stop_sequences:
- "###"
- "END"
- "STOP"
allow_multiple_tool_calls: true
chat_tool_mode: auto
""";
internal static readonly string[] s_stopSequences = ["###", "END", "STOP"];
internal static GptComponentMetadata CreateTestPromptAgent(string? publisher = "OpenAI", string? apiType = "Chat")
{
string agentYaml =
$"""
kind: Prompt
name: Test Agent
description: Test Description
instructions: You are a helpful assistant.
additionalInstructions: Provide detailed and accurate responses.
model:
id: gpt-4o
publisher: {publisher}
apiType: {apiType}
options:
modelId: gpt-4o
temperature: 0.7
maxOutputTokens: 1024
topP: 0.9
topK: 50
frequencyPenalty: 0.7
presencePenalty: 0.7
seed: 42
responseFormat: text
stopSequences:
- "###"
- "END"
- "STOP"
allowMultipleToolCalls: true
chatToolMode: auto
customProperty: customValue
connection:
kind: apiKey
endpoint: https://my-azure-openai-endpoint.openai.azure.com/
key: my-api-key
tools:
- kind: codeInterpreter
- kind: function
name: GetWeather
description: Get the weather for a given location.
parameters:
- name: location
type: string
description: The city and state, e.g. San Francisco, CA
required: true
- name: unit
type: string
description: The unit of temperature. Possible values are 'celsius' and 'fahrenheit'.
required: false
enum:
- celsius
- fahrenheit
- kind: mcp
serverName: PersonInfoTool
serverDescription: Get information about a person.
allowedTools:
- "GetPersonInfo"
- "UpdatePersonInfo"
- "DeletePersonInfo"
approvalMode:
kind: HostedMcpServerToolRequireSpecificApprovalMode
AlwaysRequireApprovalToolNames:
- "UpdatePersonInfo"
- "DeletePersonInfo"
NeverRequireApprovalToolNames:
- "GetPersonInfo"
connection:
kind: AnonymousConnection
endpoint: https://my-mcp-endpoint.com/api
- kind: webSearch
name: WebSearchTool
description: Search the web for information.
- kind: fileSearch
name: FileSearchTool
description: Search files for information.
vectorStoreIds:
- 1
- 2
- 3
outputSchema:
properties:
language:
type: string
required: true
description: The language of the answer.
answer:
type: string
required: true
description: The answer text.
""";
return AgentBotElementYaml.FromYaml(agentYaml);
}
}