mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf13e35c73 | ||
|
|
5353b9a2f0 | ||
|
|
04e711cd55 | ||
|
|
ab3d898979 | ||
|
|
02af2bc0ef | ||
|
|
e5b63a1041 | ||
|
|
b575b631c8 | ||
|
|
ce738cc6bc | ||
|
|
f99dca033f | ||
|
|
6ae32f007d | ||
|
|
039e49f353 | ||
|
|
61dbacd6f8 | ||
|
|
c7a8c12296 | ||
|
|
d714b91a14 | ||
|
|
99689add09 | ||
|
|
4fcc5a4b7d | ||
|
|
79bb87061b | ||
|
|
b3e96b80ae | ||
|
|
92df9e14bf | ||
|
|
d2d0f46e15 | ||
|
|
84e2c0cc22 | ||
|
|
4e339f841a | ||
|
|
34a00f1b8a | ||
|
|
d5165e2532 | ||
|
|
f83c39f924 | ||
|
|
037349ff90 | ||
|
|
293abf5b56 | ||
|
|
e2d2299a4f | ||
|
|
8a7260140a | ||
|
|
1da9107f4a | ||
|
|
03b74bfad4 | ||
|
|
f6cd329a32 |
@@ -204,6 +204,8 @@ agents.md
|
||||
# AI
|
||||
.claude/
|
||||
WARP.md
|
||||
**/memory-bank/
|
||||
**/projectBrief.md
|
||||
|
||||
# Azurite storage emulator files
|
||||
*/__azurite_db_blob__.json
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Declarative Agents
|
||||
|
||||
This folder contains sample agent definitions than be ran using the declarative agent support, for python see the [declarative agent python sample folder](../python/samples/getting_started/declarative/).
|
||||
@@ -0,0 +1,25 @@
|
||||
kind: Prompt
|
||||
name: Assistant
|
||||
description: Helpful assistant
|
||||
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Chat as the type in your response.
|
||||
model:
|
||||
id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME
|
||||
provider: AzureOpenAI
|
||||
apiType: Chat
|
||||
options:
|
||||
temperature: 0.9
|
||||
topP: 0.95
|
||||
outputSchema:
|
||||
properties:
|
||||
language:
|
||||
kind: string
|
||||
required: true
|
||||
description: The language of the answer.
|
||||
answer:
|
||||
kind: string
|
||||
required: true
|
||||
description: The answer text.
|
||||
type:
|
||||
kind: string
|
||||
required: true
|
||||
description: The type of the response.
|
||||
@@ -0,0 +1,25 @@
|
||||
kind: Prompt
|
||||
name: Assistant
|
||||
description: Helpful assistant
|
||||
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Assistants as the type in your response.
|
||||
model:
|
||||
id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME
|
||||
provider: AzureOpenAI
|
||||
apiType: Assistants
|
||||
options:
|
||||
temperature: 0.9
|
||||
topP: 0.95
|
||||
outputSchema:
|
||||
properties:
|
||||
language:
|
||||
kind: string
|
||||
required: true
|
||||
description: The language of the answer.
|
||||
answer:
|
||||
kind: string
|
||||
required: true
|
||||
description: The answer text.
|
||||
type:
|
||||
kind: string
|
||||
required: true
|
||||
description: The type of the response.
|
||||
@@ -0,0 +1,28 @@
|
||||
kind: Prompt
|
||||
name: Assistant
|
||||
description: Helpful assistant
|
||||
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Responses as the type in your response.
|
||||
model:
|
||||
id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME
|
||||
provider: AzureOpenAI
|
||||
apiType: Responses
|
||||
options:
|
||||
text:
|
||||
verbosity: medium
|
||||
connection:
|
||||
kind: remote
|
||||
endpoint: =Env.AZURE_OPENAI_ENDPOINT
|
||||
outputSchema:
|
||||
properties:
|
||||
language:
|
||||
kind: string
|
||||
required: true
|
||||
description: The language of the answer.
|
||||
answer:
|
||||
kind: string
|
||||
required: true
|
||||
description: The answer text.
|
||||
type:
|
||||
kind: string
|
||||
required: true
|
||||
description: The type of the response.
|
||||
@@ -0,0 +1,18 @@
|
||||
kind: Prompt
|
||||
name: Assistant
|
||||
description: Helpful assistant
|
||||
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format.
|
||||
model:
|
||||
options:
|
||||
temperature: 0.9
|
||||
topP: 0.95
|
||||
outputSchema:
|
||||
properties:
|
||||
language:
|
||||
kind: string
|
||||
required: true
|
||||
description: The language of the answer.
|
||||
answer:
|
||||
kind: string
|
||||
required: true
|
||||
description: The answer text.
|
||||
@@ -0,0 +1,27 @@
|
||||
kind: Prompt
|
||||
name: Assistant
|
||||
description: Helpful assistant
|
||||
instructions: You are a helpful assistant. You answer questions using the tools provided.
|
||||
model:
|
||||
options:
|
||||
allowMultipleToolCalls: true
|
||||
chatToolMode: auto
|
||||
tools:
|
||||
- kind: function
|
||||
name: GetWeather
|
||||
description: Get the weather for a given location.
|
||||
bindings:
|
||||
get_weather: get_weather
|
||||
parameters:
|
||||
properties:
|
||||
location:
|
||||
kind: string
|
||||
description: The city and state, e.g. San Francisco, CA
|
||||
required: true
|
||||
unit:
|
||||
kind: string
|
||||
description: The unit of temperature. Possible values are 'celsius' and 'fahrenheit'.
|
||||
required: false
|
||||
enum:
|
||||
- celsius
|
||||
- fahrenheit
|
||||
@@ -0,0 +1,21 @@
|
||||
kind: Prompt
|
||||
name: MicrosoftLearnAgent
|
||||
description: Microsoft Learn Agent
|
||||
instructions: You answer questions by searching the Microsoft Learn content only.
|
||||
model:
|
||||
id: =Env.AZURE_FOUNDRY_PROJECT_MODEL_ID
|
||||
options:
|
||||
temperature: 0.9
|
||||
topP: 0.95
|
||||
connection:
|
||||
kind: remote
|
||||
endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT
|
||||
tools:
|
||||
- kind: mcp
|
||||
name: microsoft_learn
|
||||
description: Get information from Microsoft Learn.
|
||||
url: https://learn.microsoft.com/api/mcp
|
||||
approvalMode:
|
||||
kind: never
|
||||
allowedTools:
|
||||
- microsoft_docs_search
|
||||
@@ -0,0 +1,22 @@
|
||||
kind: Prompt
|
||||
name: Assistant
|
||||
description: Helpful assistant
|
||||
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format.
|
||||
model:
|
||||
id: =Env.AZURE_FOUNDRY_PROJECT_MODEL_ID
|
||||
options:
|
||||
temperature: 0.9
|
||||
topP: 0.95
|
||||
connection:
|
||||
kind: remote
|
||||
endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT
|
||||
outputSchema:
|
||||
properties:
|
||||
language:
|
||||
kind: string
|
||||
required: true
|
||||
description: The language of the answer.
|
||||
answer:
|
||||
kind: string
|
||||
required: true
|
||||
description: The answer text.
|
||||
@@ -0,0 +1,28 @@
|
||||
kind: Prompt
|
||||
name: Assistant
|
||||
description: Helpful assistant
|
||||
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Chat as the type in your response.
|
||||
model:
|
||||
id: =Env.OPENAI_MODEL
|
||||
provider: OpenAI
|
||||
apiType: Chat
|
||||
options:
|
||||
temperature: 0.9
|
||||
topP: 0.95
|
||||
connection:
|
||||
kind: key
|
||||
key: =Env.OPENAI_API_KEY
|
||||
outputSchema:
|
||||
properties:
|
||||
language:
|
||||
kind: string
|
||||
required: true
|
||||
description: The language of the answer.
|
||||
answer:
|
||||
kind: string
|
||||
required: true
|
||||
description: The answer text.
|
||||
type:
|
||||
kind: string
|
||||
required: true
|
||||
description: The type of the response.
|
||||
@@ -0,0 +1,30 @@
|
||||
kind: Prompt
|
||||
name: Assistant
|
||||
description: Helpful assistant
|
||||
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Assistants as the type in your response.
|
||||
model:
|
||||
id: =Env.OPENAI_MODEL
|
||||
provider: OpenAI
|
||||
apiType: Assistants
|
||||
options:
|
||||
temperature: 0.9
|
||||
topP: 0.95
|
||||
connection:
|
||||
kind: key
|
||||
key: =Env.OPENAI_APIKEY
|
||||
outputSchema:
|
||||
name: AssistantResponse
|
||||
description: The response from the assistant.
|
||||
properties:
|
||||
language:
|
||||
kind: string
|
||||
required: true
|
||||
description: The language of the answer.
|
||||
answer:
|
||||
kind: string
|
||||
required: true
|
||||
description: The answer text.
|
||||
type:
|
||||
kind: string
|
||||
required: true
|
||||
description: The type of the response.
|
||||
@@ -0,0 +1,28 @@
|
||||
kind: Prompt
|
||||
name: Assistant
|
||||
description: Helpful assistant
|
||||
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Responses as the type in your response.
|
||||
model:
|
||||
id: =Env.OPENAI_MODEL
|
||||
provider: OpenAI
|
||||
apiType: Responses
|
||||
options:
|
||||
text:
|
||||
verbosity: medium
|
||||
connection:
|
||||
kind: key
|
||||
key: =Env.OPENAI_APIKEY
|
||||
outputSchema:
|
||||
properties:
|
||||
language:
|
||||
kind: string
|
||||
required: true
|
||||
description: The language of the answer.
|
||||
answer:
|
||||
kind: string
|
||||
required: true
|
||||
description: The answer text.
|
||||
type:
|
||||
kind: string
|
||||
required: true
|
||||
description: The type of the response.
|
||||
@@ -101,13 +101,14 @@
|
||||
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/AgentWithRAG_Step02_CustomVectorStoreRAG.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/AgentWithRAG_Step03_CustomRAGDataSource.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/FoundryAgents/">
|
||||
<File Path="samples/GettingStarted/FoundryAgents/README.md" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/FoundryAgents_Step03.1_UsingFunctionTools.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj" />
|
||||
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj" />
|
||||
@@ -191,7 +192,7 @@
|
||||
<Project Path="samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/Workflows/Visualization/">
|
||||
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" Id="99bf0bc6-2440-428e-b3e7-d880e4b7a5fd" />
|
||||
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/Workflows/_Foundational/">
|
||||
<Project Path="samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj" />
|
||||
@@ -374,6 +375,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
|
||||
|
||||
+18
-18
@@ -2,7 +2,7 @@
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace AgentWebChat.AgentHost;
|
||||
|
||||
@@ -10,24 +10,24 @@ internal static class ActorFrameworkWebApplicationExtensions
|
||||
{
|
||||
public static void MapAgentDiscovery(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path)
|
||||
{
|
||||
var routeGroup = endpoints.MapGroup(path);
|
||||
routeGroup.MapGet("/", async (
|
||||
AgentCatalog agentCatalog,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var results = new List<AgentDiscoveryCard>();
|
||||
await foreach (var result in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new AgentDiscoveryCard
|
||||
{
|
||||
Name = result.Name!,
|
||||
Description = result.Description,
|
||||
});
|
||||
}
|
||||
var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices<AIAgent>(KeyedService.AnyKey);
|
||||
|
||||
return Results.Ok(results);
|
||||
})
|
||||
.WithName("GetAgents");
|
||||
var routeGroup = endpoints.MapGroup(path);
|
||||
routeGroup.MapGet("/", async (CancellationToken cancellationToken) =>
|
||||
{
|
||||
var results = new List<AgentDiscoveryCard>();
|
||||
foreach (var result in registeredAIAgents)
|
||||
{
|
||||
results.Add(new AgentDiscoveryCard
|
||||
{
|
||||
Name = result.Name!,
|
||||
Description = result.Description,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(results);
|
||||
})
|
||||
.WithName("GetAgents");
|
||||
}
|
||||
|
||||
internal sealed class AgentDiscoveryCard
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
|
||||
@@ -5,6 +5,7 @@ using AgentWebChat.AgentHost;
|
||||
using AgentWebChat.AgentHost.Custom;
|
||||
using AgentWebChat.AgentHost.Utilities;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DevUI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -21,6 +22,13 @@ builder.Services.AddProblemDetails();
|
||||
// Configure the chat model and our agent.
|
||||
builder.AddKeyedChatClient("chat-model");
|
||||
|
||||
// Add DevUI services
|
||||
builder.AddDevUI();
|
||||
|
||||
// Add OpenAI services
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate",
|
||||
instructions: "You are a pirate. Speak like a pirate",
|
||||
@@ -95,8 +103,48 @@ var scienceConcurrentWorkflow = builder.AddWorkflow("science-concurrent-workflow
|
||||
return AgentWorkflowBuilder.BuildConcurrent(workflowName: key, agents: agents);
|
||||
}).AddAsAIAgent();
|
||||
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
builder.AddWorkflow("nonAgentWorkflow", (sp, key) =>
|
||||
{
|
||||
List<IHostedAgentBuilder> usedAgents = [pirateAgentBuilder, chemistryAgent];
|
||||
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents);
|
||||
});
|
||||
|
||||
builder.Services.AddKeyedSingleton("NonAgentAndNonmatchingDINameWorkflow", (sp, key) =>
|
||||
{
|
||||
List<IHostedAgentBuilder> usedAgents = [pirateAgentBuilder, chemistryAgent];
|
||||
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: "random-name", agents: agents);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<AIAgent>(sp =>
|
||||
{
|
||||
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
|
||||
return new ChatClientAgent(chatClient, name: "default-agent", instructions: "you are a default agent.");
|
||||
});
|
||||
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("my-di-nonmatching-agent", (sp, name) =>
|
||||
{
|
||||
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
|
||||
return new ChatClientAgent(
|
||||
chatClient,
|
||||
name: "some-random-name", // demonstrating registration can be different for DI and actual agent
|
||||
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
|
||||
});
|
||||
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, name) =>
|
||||
{
|
||||
if (name is not string nameStr)
|
||||
{
|
||||
throw new NotSupportedException("Name should be passed as a key");
|
||||
}
|
||||
|
||||
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
|
||||
return new ChatClientAgent(
|
||||
chatClient,
|
||||
name: nameStr, // demonstrating registration with the same name
|
||||
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -118,7 +166,10 @@ app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard
|
||||
// Url = "http://localhost:5390/a2a/knights-and-knaves"
|
||||
});
|
||||
|
||||
app.MapDevUI();
|
||||
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.MapOpenAIChatCompletions(pirateAgentBuilder);
|
||||
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
|
||||
|
||||
@@ -9,7 +9,9 @@ var azOpenAiResourceGroup = builder.AddParameterFromConfiguration("AzureOpenAIRe
|
||||
var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup));
|
||||
|
||||
var agentHost = builder.AddProject<Projects.AgentWebChat_AgentHost>("agenthost")
|
||||
.WithReference(chatModel);
|
||||
.WithHttpEndpoint(name: "devui")
|
||||
.WithUrlForEndpoint("devui", (url) => new() { Url = "/devui", DisplayText = "Dev UI" })
|
||||
.WithReference(chatModel);
|
||||
|
||||
builder.AddProject<Projects.AgentWebChat_Web>("webfrontend")
|
||||
.WithExternalHttpEndpoints()
|
||||
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -11,7 +11,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Plugins.OpenApi" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -19,8 +18,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="OpenAPISpec.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<None Update="contoso-outdoors-knowledge-base.md">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use the built in RAG capabilities that the Foundry service provides when using AI Agents provided by Foundry.
|
||||
|
||||
using System.ClientModel;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.VectorStores;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// Create an AI Project client and get an OpenAI client that works with the foundry service.
|
||||
AIProjectClient aiProjectClient = new(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential());
|
||||
OpenAIClient openAIClient = aiProjectClient.GetProjectOpenAIClient();
|
||||
|
||||
// Upload the file that contains the data to be used for RAG to the Foundry service.
|
||||
OpenAIFileClient fileClient = openAIClient.GetOpenAIFileClient();
|
||||
ClientResult<OpenAIFile> uploadResult = await fileClient.UploadFileAsync(
|
||||
filePath: "contoso-outdoors-knowledge-base.md",
|
||||
purpose: FileUploadPurpose.Assistants);
|
||||
|
||||
// Create a vector store in the Foundry service using the uploaded file.
|
||||
VectorStoreClient vectorStoreClient = openAIClient.GetVectorStoreClient();
|
||||
ClientResult<VectorStore> vectorStoreCreate = await vectorStoreClient.CreateVectorStoreAsync(options: new VectorStoreCreationOptions()
|
||||
{
|
||||
Name = "contoso-outdoors-knowledge-base",
|
||||
FileIds = { uploadResult.Value.Id }
|
||||
});
|
||||
|
||||
var fileSearchTool = new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreCreate.Value.Id)] };
|
||||
|
||||
AIAgent agent = await aiProjectClient
|
||||
.CreateAIAgentAsync(
|
||||
model: deploymentName,
|
||||
name: "AskContoso",
|
||||
instructions: "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
tools: [fileSearchTool]);
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
Console.WriteLine(">> Asking about returns\n");
|
||||
Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about shipping\n");
|
||||
Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about product care\n");
|
||||
Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread));
|
||||
|
||||
// Cleanup
|
||||
await fileClient.DeleteFileAsync(uploadResult.Value.Id);
|
||||
await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreCreate.Value.Id);
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Contoso Outdoors Knowledge Base
|
||||
|
||||
## Contoso Outdoors Return Policy
|
||||
|
||||
Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection.
|
||||
|
||||
## Contoso Outdoors Shipping Guide
|
||||
|
||||
Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout.
|
||||
|
||||
## Product Information
|
||||
|
||||
### TrailRunner Tent
|
||||
|
||||
The TrailRunner Tent is a lightweight, 2-person tent designed for easy setup and durability. It features waterproof materials, ventilation windows, and a compact carry bag.
|
||||
|
||||
#### Care Instructions
|
||||
|
||||
Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating.
|
||||
@@ -7,3 +7,4 @@ These samples show how to create an agent with the Agent Framework that uses Ret
|
||||
|[Basic Text RAG](./AgentWithRAG_Step01_BasicTextRAG/)|This sample demonstrates how to create and run a basic agent with simple text Retrieval Augmented Generation (RAG).|
|
||||
|[RAG with Vector Store and custom schema](./AgentWithRAG_Step02_CustomVectorStoreRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with a vector store. It also uses a custom schema for the documents stored in the vector store.|
|
||||
|[RAG with custom RAG data source](./AgentWithRAG_Step03_CustomRAGDataSource/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with a custom RAG data source.|
|
||||
|[RAG with Foundry VectorStore service](./AgentWithRAG_Step04_FoundryServiceRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with the Foundry VectorStore service.|
|
||||
|
||||
+6
-9
@@ -11,14 +11,15 @@ using Microsoft.Extensions.AI;
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
const string JokerInstructions = "You are good at telling jokes.";
|
||||
const string JokerInstructionsV1 = "You are good at telling jokes.";
|
||||
const string JokerInstructionsV2 = "You are extremely hilarious at telling jokes.";
|
||||
const string JokerName = "JokerAgent";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
// Define the agent you want to create. (Prompt Agent in this case)
|
||||
AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions });
|
||||
AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructionsV1 });
|
||||
|
||||
// Azure.AI.Agents SDK creates and manages agent by name and versions.
|
||||
// You can create a server side agent version with the Azure.AI.Agents SDK client below.
|
||||
@@ -32,8 +33,8 @@ AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName:
|
||||
// You can retrieve an AIAgent for an already created server side agent version.
|
||||
AIAgent jokerAgentV1 = aiProjectClient.GetAIAgent(agentVersion);
|
||||
|
||||
// You can also create another AIAgent version (V2) by providing the same name with a different definition.
|
||||
AIAgent jokerAgentV2 = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions + "V2");
|
||||
// You can also create another AIAgent version (V2) by providing the same name with a different definition/instruction.
|
||||
AIAgent jokerAgentV2 = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructionsV2);
|
||||
|
||||
// You can also get the AIAgent latest version by just providing its name.
|
||||
AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName);
|
||||
@@ -43,11 +44,7 @@ AgentVersion latestVersion = jokerAgentLatest.GetService<AgentVersion>()!;
|
||||
Console.WriteLine($"Latest agent version id: {latestVersion.Id}");
|
||||
|
||||
// Once you have the AIAgent, you can invoke it like any other AIAgent.
|
||||
AgentThread thread = jokerAgentLatest.GetNewThread();
|
||||
Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate.", thread));
|
||||
|
||||
// This will use the same thread to continue the conversation.
|
||||
Console.WriteLine(await jokerAgentLatest.RunAsync("Now tell me a joke about a cat and a dog using last joke as the anchor.", thread));
|
||||
Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate."));
|
||||
|
||||
// Cleanup by agent name removes both agent versions created (jokerAgentV1 + jokerAgentV2).
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(jokerAgentV1.Name);
|
||||
|
||||
+1
-6
@@ -26,13 +26,8 @@ AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName:
|
||||
// You can retrieve an AIAgent for a already created server side agent version.
|
||||
AIAgent jokerAgent = aiProjectClient.GetAIAgent(agentVersion);
|
||||
|
||||
// Invoke the agent and output the text result.
|
||||
AgentThread thread = jokerAgent.GetNewThread();
|
||||
Console.WriteLine(await jokerAgent.RunAsync("Tell me a joke about a pirate.", thread));
|
||||
|
||||
// Invoke the agent with streaming support.
|
||||
thread = jokerAgent.GetNewThread();
|
||||
await foreach (AgentRunResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate.", thread))
|
||||
await foreach (AgentRunResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate."))
|
||||
{
|
||||
Console.WriteLine(update);
|
||||
}
|
||||
|
||||
-354
@@ -1,354 +0,0 @@
|
||||
{
|
||||
"openapi": "3.0.1",
|
||||
"info": {
|
||||
"title": "Github Versions API",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://api.github.com"
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"schemas": {
|
||||
"basic-error": {
|
||||
"title": "Basic Error",
|
||||
"description": "Basic Error",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"documentation_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"title": "Label",
|
||||
"description": "Color-coded labels help you categorize and filter your issues (just like labels in Gmail).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Unique identifier for the label.",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"example": 208045946
|
||||
},
|
||||
"node_id": {
|
||||
"type": "string",
|
||||
"example": "MDU6TGFiZWwyMDgwNDU5NDY="
|
||||
},
|
||||
"url": {
|
||||
"description": "URL for the label",
|
||||
"example": "https://api.github.com/repositories/42/labels/bug",
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name of the label.",
|
||||
"example": "bug",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Optional description of the label, such as its purpose.",
|
||||
"type": "string",
|
||||
"example": "Something isn't working",
|
||||
"nullable": true
|
||||
},
|
||||
"color": {
|
||||
"description": "6-character hex code, without the leading #, identifying the color",
|
||||
"example": "FFFFFF",
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"description": "Whether this label comes by default in a new repository.",
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"node_id",
|
||||
"url",
|
||||
"name",
|
||||
"description",
|
||||
"color",
|
||||
"default"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"title": "Tag",
|
||||
"description": "Tag",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "v0.1"
|
||||
},
|
||||
"commit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sha": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sha",
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"zipball_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"example": "https://github.com/octocat/Hello-World/zipball/v0.1"
|
||||
},
|
||||
"tarball_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"example": "https://github.com/octocat/Hello-World/tarball/v0.1"
|
||||
},
|
||||
"node_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"node_id",
|
||||
"commit",
|
||||
"zipball_url",
|
||||
"tarball_url"
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"label-items": {
|
||||
"value": [
|
||||
{
|
||||
"id": 208045946,
|
||||
"node_id": "MDU6TGFiZWwyMDgwNDU5NDY=",
|
||||
"url": "https://api.github.com/repos/octocat/Hello-World/labels/bug",
|
||||
"name": "bug",
|
||||
"description": "Something isn't working",
|
||||
"color": "f29513",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"id": 208045947,
|
||||
"node_id": "MDU6TGFiZWwyMDgwNDU5NDc=",
|
||||
"url": "https://api.github.com/repos/octocat/Hello-World/labels/enhancement",
|
||||
"name": "enhancement",
|
||||
"description": "New feature or request",
|
||||
"color": "a2eeef",
|
||||
"default": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"tag-items": {
|
||||
"value": [
|
||||
{
|
||||
"name": "v0.1",
|
||||
"commit": {
|
||||
"sha": "c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc",
|
||||
"url": "https://api.github.com/repos/octocat/Hello-World/commits/c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc"
|
||||
},
|
||||
"zipball_url": "https://github.com/octocat/Hello-World/zipball/v0.1",
|
||||
"tarball_url": "https://github.com/octocat/Hello-World/tarball/v0.1",
|
||||
"node_id": "MDQ6VXNlcjE="
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"owner": {
|
||||
"name": "owner",
|
||||
"description": "The account owner of the repository. The name is not case sensitive.",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"repo": {
|
||||
"name": "repo",
|
||||
"description": "The name of the repository without the `.git` extension. The name is not case sensitive.",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"per-page": {
|
||||
"name": "per_page",
|
||||
"description": "The number of results per page (max 100). For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 30
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"name": "page",
|
||||
"description": "The page number of the results to fetch. For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"not_found": {
|
||||
"description": "Resource not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/basic-error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"link": {
|
||||
"example": "<https://api.github.com/resource?page=2>; rel=\"next\", <https://api.github.com/resource?page=5>; rel=\"last\"",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/repos/{owner}/{repo}/tags": {
|
||||
"get": {
|
||||
"summary": "List repository tags",
|
||||
"description": "",
|
||||
"tags": [
|
||||
"repos"
|
||||
],
|
||||
"operationId": "repos/list-tags",
|
||||
"externalDocs": {
|
||||
"description": "API method documentation",
|
||||
"url": "https://docs.github.com/rest/repos/repos#list-repository-tags"
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/owner"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/repo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/per-page"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/page"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/tag"
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"default": {
|
||||
"$ref": "#/components/examples/tag-items"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Link": {
|
||||
"$ref": "#/components/headers/link"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-github": {
|
||||
"githubCloudOnly": false,
|
||||
"enabledForGitHubApps": true,
|
||||
"category": "repos",
|
||||
"subcategory": "repos"
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/labels": {
|
||||
"get": {
|
||||
"summary": "List labels for a repository",
|
||||
"description": "Lists all labels for a repository.",
|
||||
"tags": [
|
||||
"issues"
|
||||
],
|
||||
"operationId": "issues/list-labels-for-repo",
|
||||
"externalDocs": {
|
||||
"description": "API method documentation",
|
||||
"url": "https://docs.github.com/rest/issues/labels#list-labels-for-a-repository"
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/owner"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/repo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/per-page"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/page"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/label"
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"default": {
|
||||
"$ref": "#/components/examples/label-items"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Link": {
|
||||
"$ref": "#/components/headers/link"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/not_found"
|
||||
}
|
||||
},
|
||||
"x-github": {
|
||||
"githubCloudOnly": false,
|
||||
"enabledForGitHubApps": true,
|
||||
"category": "issues",
|
||||
"subcategory": "labels"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use an agent with function tools provided via an OpenAPI spec.
|
||||
// It uses functionality from Semantic Kernel to parse the OpenAPI spec and create function tools to use with the Agent Framework Agent.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.SemanticKernel;
|
||||
using Microsoft.SemanticKernel.Plugins.OpenApi;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// Load the OpenAPI Spec from a file.
|
||||
KernelPlugin plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("github", "OpenAPISpec.json");
|
||||
|
||||
// Convert the Semantic Kernel plugin to Agent Framework function tools.
|
||||
// This requires a dummy Kernel instance, since KernelFunctions cannot execute without one.
|
||||
Kernel kernel = new();
|
||||
List<AITool> tools = plugin.Select(x => x.WithKernel(kernel)).Cast<AITool>().ToList();
|
||||
|
||||
const string AssistantInstructions = "You are a helpful assistant that can query GitHub repositories.";
|
||||
const string AssistantName = "GitHubAssistant";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
// Create AIAgent directly
|
||||
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: tools);
|
||||
|
||||
// Run the agent with the OpenAPI function tools.
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
Console.WriteLine(await agent.RunAsync("Please list the names, colors and descriptions of all the labels available in the microsoft/agent-framework repository on github.", thread));
|
||||
|
||||
// Cleanup by agent name removes the agent version created.
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
# Using Function Tools from OpenAPI Specifications
|
||||
|
||||
This sample demonstrates how to create function tools from an OpenAPI specification and use them with AI agents.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Loading OpenAPI specifications from files
|
||||
- Converting OpenAPI specifications to Semantic Kernel plugins
|
||||
- Converting Semantic Kernel plugins to AI function tools
|
||||
- Using OpenAPI-based function tools with AI agents
|
||||
- Managing agent lifecycle (creation and deletion)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 8.0 SDK or later
|
||||
- Azure Foundry service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
|
||||
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint
|
||||
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
Navigate to the FoundryAgents sample directory and run:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/GettingStarted/FoundryAgents
|
||||
dotnet run --project .\FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
The sample will:
|
||||
|
||||
1. Load the OpenAPI specification from OpenAPISpec.json (GitHub API)
|
||||
2. Convert the OpenAPI spec to Semantic Kernel plugins
|
||||
3. Create an agent named "GitHubAssistant" with the OpenAPI-based function tools
|
||||
4. Run the agent with a prompt to query GitHub repositories
|
||||
5. The agent will invoke the appropriate OpenAPI function tools to retrieve data
|
||||
6. Clean up resources by deleting the agent
|
||||
|
||||
+14
-6
@@ -26,18 +26,26 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential(
|
||||
AITool tool = AIFunctionFactory.Create(GetWeather);
|
||||
|
||||
// Create AIAgent directly
|
||||
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [tool]);
|
||||
var newAgent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [tool]);
|
||||
|
||||
// Getting an already existing agent by name with tools.
|
||||
/*
|
||||
* IMPORTANT: Since agents that are stored in the server only know the definition of the function tools (JSON Schema),
|
||||
* you need to provided all invocable function tools when retrieving the agent so it can invoke them automatically.
|
||||
* If no invocable tools are provided, the function calling needs to handled manually.
|
||||
*/
|
||||
var existingAgent = await aiProjectClient.GetAIAgentAsync(name: AssistantName, tools: [tool]);
|
||||
|
||||
// Non-streaming agent interaction with function tools.
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?", thread));
|
||||
AgentThread thread = existingAgent.GetNewThread();
|
||||
Console.WriteLine(await existingAgent.RunAsync("What is the weather like in Amsterdam?", thread));
|
||||
|
||||
// Streaming agent interaction with function tools.
|
||||
thread = agent.GetNewThread();
|
||||
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync("What is the weather like in Amsterdam?", thread))
|
||||
thread = existingAgent.GetNewThread();
|
||||
await foreach (AgentRunResponseUpdate update in existingAgent.RunStreamingAsync("What is the weather like in Amsterdam?", thread))
|
||||
{
|
||||
Console.WriteLine(update);
|
||||
}
|
||||
|
||||
// Cleanup by agent name removes the agent version created.
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(existingAgent.Name);
|
||||
+1
-1
@@ -25,7 +25,7 @@ const string AssistantName = "WeatherAssistant";
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
ApprovalRequiredAIFunction approvalTool = new(AIFunctionFactory.Create(GetWeather));
|
||||
ApprovalRequiredAIFunction approvalTool = new(AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather)));
|
||||
|
||||
// Create AIAgent directly
|
||||
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [approvalTool]);
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
+6
@@ -16,5 +16,11 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Assets\walkway.jpg">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = System.Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
|
||||
const string VisionInstructions = "You are a helpful agent that can analyze images";
|
||||
const string VisionName = "VisionAgent";
|
||||
@@ -21,7 +21,7 @@ AIAgent agent = aiProjectClient.CreateAIAgent(name: VisionName, model: deploymen
|
||||
|
||||
ChatMessage message = new(ChatRole.User, [
|
||||
new TextContent("What do you see in this image?"),
|
||||
new UriContent("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "image/jpeg")
|
||||
new DataContent(File.ReadAllBytes("assets/walkway.jpg"), "image/jpeg")
|
||||
]);
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ AgentRunResponse response = await agentOption1.RunAsync("I need to solve the equ
|
||||
// AgentRunResponse response = await agentOption2.RunAsync("I need to solve the equation sin(x) + x^2 = 42");
|
||||
|
||||
// Get the CodeInterpreterToolCallContent
|
||||
CodeInterpreterToolCallContent? toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType<CodeInterpreterToolCallContent>().SingleOrDefault();
|
||||
CodeInterpreterToolCallContent? toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType<CodeInterpreterToolCallContent>().FirstOrDefault();
|
||||
if (toolCallContent?.Inputs is not null)
|
||||
{
|
||||
DataContent? codeInput = toolCallContent.Inputs.OfType<DataContent>().FirstOrDefault();
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ internal sealed class Program
|
||||
{
|
||||
private static async Task Main(string[] args)
|
||||
{
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "computer-use-preview";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "computer-use-preview";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ Before you begin, ensure you have the following prerequisites:
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="computer-use-preview" # Optional, defaults to computer-use-preview
|
||||
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint
|
||||
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="computer-use-preview" # Optional, defaults to computer-use-preview
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
@@ -25,8 +25,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Basics](./FoundryAgents_Step01.1_Basics/)|This sample demonstrates how to create and manage AI agents with versioning|
|
||||
|[Running a simple agent](./FoundryAgents_Step01.2_Running/)|This sample demonstrates how to create and run a basic Foundry agent|
|
||||
|[Multi-turn conversation](./FoundryAgents_Step02_MultiturnConversation/)|This sample demonstrates how to implement a multi-turn conversation with a Foundry agent|
|
||||
|[Using function tools](./FoundryAgents_Step03.1_UsingFunctionTools/)|This sample demonstrates how to use function tools with a Foundry agent|
|
||||
|[Using OpenAPI function tools](./FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/)|This sample demonstrates how to create function tools from an OpenAPI spec and use them with a Foundry agent|
|
||||
|[Using function tools](./FoundryAgents_Step03_UsingFunctionTools/)|This sample demonstrates how to use function tools with a Foundry agent|
|
||||
|[Using function tools with approvals](./FoundryAgents_Step04_UsingFunctionToolsWithApprovals/)|This sample demonstrates how to use function tools where approvals require human in the loop approvals before execution|
|
||||
|[Structured output](./FoundryAgents_Step05_StructuredOutput/)|This sample demonstrates how to use structured output with a Foundry agent|
|
||||
|[Persisted conversations](./FoundryAgents_Step06_PersistedConversations/)|This sample demonstrates how to persist conversations and reload them later|
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.Agents.AI.DevUI.Entities;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -27,21 +24,26 @@ internal static class EntitiesApiExtensions
|
||||
/// <item><description>GET /v1/entities/{entityId}/info - Get detailed information about a specific entity</description></item>
|
||||
/// </list>
|
||||
/// The endpoints are compatible with the Python DevUI frontend and automatically discover entities
|
||||
/// from the registered <see cref="AgentCatalog"/> and <see cref="WorkflowCatalog"/> services.
|
||||
/// from the registered <see cref="AIAgent">agents</see> and <see cref="Workflow">workflows</see> in the dependency injection container.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var registeredAIAgents = GetRegisteredEntities<AIAgent>(endpoints.ServiceProvider);
|
||||
var registeredWorkflows = GetRegisteredEntities<Workflow>(endpoints.ServiceProvider);
|
||||
|
||||
var group = endpoints.MapGroup("/v1/entities")
|
||||
.WithTags("Entities");
|
||||
|
||||
// List all entities
|
||||
group.MapGet("", ListEntitiesAsync)
|
||||
group.MapGet("", (CancellationToken cancellationToken)
|
||||
=> ListEntitiesAsync(registeredAIAgents, registeredWorkflows, cancellationToken))
|
||||
.WithName("ListEntities")
|
||||
.WithSummary("List all registered entities (agents and workflows)")
|
||||
.Produces<DiscoveryResponse>(StatusCodes.Status200OK, contentType: "application/json");
|
||||
|
||||
// Get detailed entity information
|
||||
group.MapGet("{entityId}/info", GetEntityInfoAsync)
|
||||
group.MapGet("{entityId}/info", (string entityId, string? type, CancellationToken cancellationToken)
|
||||
=> GetEntityInfoAsync(entityId, type, registeredAIAgents, registeredWorkflows, cancellationToken))
|
||||
.WithName("GetEntityInfo")
|
||||
.WithSummary("Get detailed information about a specific entity")
|
||||
.Produces<EntityInfo>(StatusCodes.Status200OK, contentType: "application/json")
|
||||
@@ -51,8 +53,8 @@ internal static class EntitiesApiExtensions
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListEntitiesAsync(
|
||||
AgentCatalog? agentCatalog,
|
||||
WorkflowCatalog? workflowCatalog,
|
||||
IEnumerable<AIAgent> agents,
|
||||
IEnumerable<Workflow> workflows,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -60,13 +62,13 @@ internal static class EntitiesApiExtensions
|
||||
var entities = new Dictionary<string, EntityInfo>();
|
||||
|
||||
// Discover agents
|
||||
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
|
||||
foreach (var agentInfo in DiscoverAgents(agents, entityIdFilter: null))
|
||||
{
|
||||
entities[agentInfo.Id] = agentInfo;
|
||||
}
|
||||
|
||||
// Discover workflows
|
||||
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
|
||||
foreach (var workflowInfo in DiscoverWorkflows(workflows, entityIdFilter: null))
|
||||
{
|
||||
entities[workflowInfo.Id] = workflowInfo;
|
||||
}
|
||||
@@ -85,15 +87,15 @@ internal static class EntitiesApiExtensions
|
||||
private static async Task<IResult> GetEntityInfoAsync(
|
||||
string entityId,
|
||||
string? type,
|
||||
AgentCatalog? agentCatalog,
|
||||
WorkflowCatalog? workflowCatalog,
|
||||
IEnumerable<AIAgent> agents,
|
||||
IEnumerable<Workflow> workflows,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (type is null || string.Equals(type, "workflow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityId, cancellationToken).ConfigureAwait(false))
|
||||
foreach (var workflowInfo in DiscoverWorkflows(workflows, entityId))
|
||||
{
|
||||
return Results.Json(workflowInfo, EntitiesJsonContext.Default.EntityInfo);
|
||||
}
|
||||
@@ -101,7 +103,7 @@ internal static class EntitiesApiExtensions
|
||||
|
||||
if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false))
|
||||
foreach (var agentInfo in DiscoverAgents(agents, entityId))
|
||||
{
|
||||
return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo);
|
||||
}
|
||||
@@ -118,17 +120,9 @@ internal static class EntitiesApiExtensions
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<EntityInfo> DiscoverAgentsAsync(
|
||||
AgentCatalog? agentCatalog,
|
||||
string? entityIdFilter,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
private static IEnumerable<EntityInfo> DiscoverAgents(IEnumerable<AIAgent> agents, string? entityIdFilter)
|
||||
{
|
||||
if (agentCatalog is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
// If filtering by entity ID, skip non-matching agents
|
||||
if (entityIdFilter is not null &&
|
||||
@@ -148,17 +142,9 @@ internal static class EntitiesApiExtensions
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<EntityInfo> DiscoverWorkflowsAsync(
|
||||
WorkflowCatalog? workflowCatalog,
|
||||
string? entityIdFilter,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
private static IEnumerable<EntityInfo> DiscoverWorkflows(IEnumerable<Workflow> workflows, string? entityIdFilter)
|
||||
{
|
||||
if (workflowCatalog is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
|
||||
foreach (var workflow in workflows)
|
||||
{
|
||||
var workflowId = workflow.Name ?? workflow.StartExecutorId;
|
||||
|
||||
@@ -304,4 +290,14 @@ internal static class EntitiesApiExtensions
|
||||
StartExecutorId = workflow.StartExecutorId
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<T> GetRegisteredEntities<T>(IServiceProvider serviceProvider)
|
||||
{
|
||||
var keyedEntities = serviceProvider.GetKeyedServices<T>(KeyedService.AnyKey);
|
||||
var defaultEntities = serviceProvider.GetServices<T>() ?? [];
|
||||
|
||||
return keyedEntities
|
||||
.Concat(defaultEntities)
|
||||
.Where(entity => entity is not null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IHostApplicationBuilder"/> to configure DevUI.
|
||||
/// </summary>
|
||||
public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds DevUI services to the host application builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
|
||||
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
|
||||
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.AddDevUI();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net9.0</TargetFrameworks>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Microsoft.Agents.AI.DevUI</RootNamespace>
|
||||
@@ -12,6 +13,10 @@
|
||||
<NoWarn>$(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Import nuget packaging properties -->
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
@@ -33,4 +38,7 @@
|
||||
<Description>Provides Microsoft Agent Framework support for developer UI.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.DevUI.UnitTests"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -24,9 +24,15 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// Register your agents
|
||||
builder.AddAIAgent("assistant", "You are a helpful assistant.");
|
||||
|
||||
// Register DevUI services
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.AddDevUI();
|
||||
}
|
||||
|
||||
// Register services for OpenAI responses and conversations (also required for DevUI)
|
||||
builder.Services.AddOpenAIResponses();
|
||||
builder.Services.AddOpenAIConversations();
|
||||
builder.AddOpenAIResponses();
|
||||
builder.AddOpenAIConversations();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IServiceCollection"/> to configure DevUI.
|
||||
/// </summary>
|
||||
public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds services required for DevUI integration.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
|
||||
public static IServiceCollection AddDevUI(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// a factory that tries to construct an AIAgent from Workflow,
|
||||
// even if workflow was not explicitly registered as an AIAgent.
|
||||
|
||||
#pragma warning disable IDE0001 // Simplify Names
|
||||
services.AddKeyedSingleton<AIAgent>(KeyedService.AnyKey, (sp, key) =>
|
||||
{
|
||||
var keyAsStr = key as string;
|
||||
Throw.IfNullOrEmpty(keyAsStr);
|
||||
|
||||
var workflow = sp.GetKeyedService<Workflow>(keyAsStr);
|
||||
if (workflow is not null)
|
||||
{
|
||||
return workflow.AsAgent(name: workflow.Name);
|
||||
}
|
||||
|
||||
// another thing we can do is resolve a non-keyed workflow.
|
||||
// however, we can't rely on anything than key to be equal to the workflow.Name.
|
||||
// so we try: if we fail, we return null.
|
||||
workflow = sp.GetService<Workflow>();
|
||||
if (workflow is not null && workflow.Name?.Equals(keyAsStr, StringComparison.Ordinal) == true)
|
||||
{
|
||||
return workflow.AsAgent(name: workflow.Name);
|
||||
}
|
||||
|
||||
// and it's possible to lookup at the default-registered AIAgent
|
||||
// with the condition of same name as the key.
|
||||
var agent = sp.GetService<AIAgent>();
|
||||
if (agent is not null && agent.Name?.Equals(keyAsStr, StringComparison.Ordinal) == true)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
return null!;
|
||||
});
|
||||
#pragma warning restore IDE0001 // Simplify Names
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -63,7 +63,11 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
return ValueTask.FromResult<ResponseError?>(new ResponseError
|
||||
{
|
||||
Code = "agent_not_found",
|
||||
Message = $"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent()."
|
||||
Message = $"""
|
||||
Agent '{agentName}' not found.
|
||||
Ensure the agent is registered with '{agentName}' name in the dependency injection container.
|
||||
We recommend using 'builder.AddAIAgent()' for simplicity.
|
||||
"""
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a catalog of registered AI agents within the hosting environment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The agent catalog allows enumeration of all registered agents in the dependency injection container.
|
||||
/// This is useful for scenarios where you need to discover and interact with multiple agents programmatically.
|
||||
/// </remarks>
|
||||
public abstract class AgentCatalog
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentCatalog"/> class.
|
||||
/// </summary>
|
||||
protected AgentCatalog()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves all registered AI agents from the catalog.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>
|
||||
/// An asynchronous enumerable of <see cref="AIAgent"/> instances representing all registered agents.
|
||||
/// The enumeration will only include agents that are successfully resolved from the service provider.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This method enumerates through all registered agent names and attempts to resolve each agent
|
||||
/// from the dependency injection container. Only successfully resolved agents are yielded.
|
||||
/// The enumeration is lazy and agents are resolved on-demand during iteration.
|
||||
/// </remarks>
|
||||
public abstract IAsyncEnumerable<AIAgent> GetAgentsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.Hosting.Local;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -126,31 +125,9 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
return agent;
|
||||
});
|
||||
|
||||
// Register the agent by name for discovery.
|
||||
var agentHostBuilder = GetAgentRegistry(services);
|
||||
agentHostBuilder.AgentNames.Add(name);
|
||||
|
||||
return new HostedAgentBuilder(name, services);
|
||||
}
|
||||
|
||||
private static LocalAgentRegistry GetAgentRegistry(IServiceCollection services)
|
||||
{
|
||||
var descriptor = services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalAgentRegistry)));
|
||||
if (descriptor?.ImplementationInstance is not LocalAgentRegistry instance)
|
||||
{
|
||||
instance = new LocalAgentRegistry();
|
||||
ConfigureHostBuilder(services, instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static void ConfigureHostBuilder(IServiceCollection services, LocalAgentRegistry agentHostBuilderContext)
|
||||
{
|
||||
services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext));
|
||||
services.AddSingleton<AgentCatalog, LocalAgentCatalog>();
|
||||
}
|
||||
|
||||
private static IList<AITool> GetRegisteredToolsForAgent(IServiceProvider serviceProvider, string agentName)
|
||||
{
|
||||
var registry = serviceProvider.GetService<LocalAgentToolRegistry>();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.Hosting.Local;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -47,28 +45,6 @@ public static class HostApplicationBuilderWorkflowExtensions
|
||||
return workflow;
|
||||
});
|
||||
|
||||
// Register the workflow by name for discovery.
|
||||
var workflowRegistry = GetWorkflowRegistry(builder);
|
||||
workflowRegistry.WorkflowNames.Add(name);
|
||||
|
||||
return new HostedWorkflowBuilder(name, builder);
|
||||
}
|
||||
|
||||
private static LocalWorkflowRegistry GetWorkflowRegistry(IHostApplicationBuilder builder)
|
||||
{
|
||||
var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalWorkflowRegistry)));
|
||||
if (descriptor?.ImplementationInstance is not LocalWorkflowRegistry instance)
|
||||
{
|
||||
instance = new LocalWorkflowRegistry();
|
||||
ConfigureHostBuilder(builder, instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalWorkflowRegistry agentHostBuilderContext)
|
||||
{
|
||||
builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext));
|
||||
builder.Services.AddSingleton<WorkflowCatalog, LocalWorkflowCatalog>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.Local;
|
||||
|
||||
// Implementation of an AgentCatalog which enumerates agents registered in the local service provider.
|
||||
internal sealed class LocalAgentCatalog : AgentCatalog
|
||||
{
|
||||
public readonly HashSet<string> _registeredAgents;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public LocalAgentCatalog(LocalAgentRegistry agentHostBuilder, IServiceProvider serviceProvider)
|
||||
{
|
||||
this._registeredAgents = [.. agentHostBuilder.AgentNames];
|
||||
this._serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<AIAgent> GetAgentsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
|
||||
foreach (var name in this._registeredAgents)
|
||||
{
|
||||
var agent = this._serviceProvider.GetKeyedService<AIAgent>(name);
|
||||
if (agent is not null)
|
||||
{
|
||||
yield return agent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.Local;
|
||||
|
||||
internal sealed class LocalAgentRegistry
|
||||
{
|
||||
public HashSet<string> AgentNames { get; } = [];
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.Local;
|
||||
|
||||
internal sealed class LocalWorkflowCatalog : WorkflowCatalog
|
||||
{
|
||||
public readonly HashSet<string> _registeredWorkflows;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public LocalWorkflowCatalog(LocalWorkflowRegistry workflowRegistry, IServiceProvider serviceProvider)
|
||||
{
|
||||
this._registeredWorkflows = [.. workflowRegistry.WorkflowNames];
|
||||
this._serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<Workflow> GetWorkflowsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
|
||||
foreach (var name in this._registeredWorkflows)
|
||||
{
|
||||
var workflow = this._serviceProvider.GetKeyedService<Workflow>(name);
|
||||
if (workflow is not null)
|
||||
{
|
||||
yield return workflow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.Local;
|
||||
|
||||
internal sealed class LocalWorkflowRegistry
|
||||
{
|
||||
public HashSet<string> WorkflowNames { get; } = [];
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for DevUI service collection extensions.
|
||||
/// Tests verify that workflows and agents can be resolved even when registered non-conventionally.
|
||||
/// </summary>
|
||||
public class DevUIExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI throws ArgumentNullException when services collection is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_NullServices_ThrowsArgumentNullException()
|
||||
{
|
||||
IServiceCollection services = null!;
|
||||
Assert.Throws<ArgumentNullException>(() => services.AddDevUI());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetRequiredKeyedService throws for non-existent keys.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_GetRequiredKeyedServiceNonExistent_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddDevUI();
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => serviceProvider.GetRequiredKeyedService<AIAgent>("non-existent"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an agent with null name can be resolved by its workflow.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_WorkflowWithName_CanBeResolved_AsAIAgent()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
|
||||
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
|
||||
|
||||
services.AddKeyedSingleton("workflow", workflow);
|
||||
services.AddDevUI();
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
// Act
|
||||
var resolvedWorkflowAsAgent = serviceProvider.GetKeyedService<AIAgent>("workflow");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resolvedWorkflowAsAgent);
|
||||
Assert.Null(resolvedWorkflowAsAgent.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an agent with null name can be resolved by its workflow.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_MultipleWorkflowsWithName_CanBeResolved_AsAIAgent()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
|
||||
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
|
||||
var workflow1 = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
|
||||
var workflow2 = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
|
||||
|
||||
services.AddKeyedSingleton("workflow1", workflow1);
|
||||
services.AddKeyedSingleton("workflow2", workflow2);
|
||||
services.AddDevUI();
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
var resolvedWorkflow1AsAgent = serviceProvider.GetKeyedService<AIAgent>("workflow1");
|
||||
Assert.NotNull(resolvedWorkflow1AsAgent);
|
||||
Assert.Null(resolvedWorkflow1AsAgent.Name);
|
||||
|
||||
var resolvedWorkflow2AsAgent = serviceProvider.GetKeyedService<AIAgent>("workflow2");
|
||||
Assert.NotNull(resolvedWorkflow2AsAgent);
|
||||
Assert.Null(resolvedWorkflow2AsAgent.Name);
|
||||
|
||||
Assert.False(resolvedWorkflow1AsAgent == resolvedWorkflow2AsAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an agent with null name can be resolved by its workflow.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_NonKeyedWorkflow_CanBeResolved_AsAIAgent()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
|
||||
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
|
||||
|
||||
services.AddKeyedSingleton("workflow", workflow);
|
||||
services.AddDevUI();
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
var resolvedWorkflowAsAgent = serviceProvider.GetKeyedService<AIAgent>("workflow");
|
||||
Assert.NotNull(resolvedWorkflowAsAgent);
|
||||
Assert.Null(resolvedWorkflowAsAgent.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an agent with null name can be resolved by its workflow.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_NonKeyedWorkflow_PlusKeyedWorkflow_CanBeResolved_AsAIAgent()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
|
||||
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("standardname", agent1, agent2);
|
||||
var keyedWorkflow = AgentWorkflowBuilder.BuildSequential("keyedname", agent1, agent2);
|
||||
|
||||
services.AddSingleton(workflow);
|
||||
services.AddKeyedSingleton("keyed", keyedWorkflow);
|
||||
services.AddDevUI();
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
// resolve a workflow with the same name as workflow's name (which is registered without a key)
|
||||
var standardAgent = serviceProvider.GetKeyedService<AIAgent>("standardname");
|
||||
Assert.NotNull(standardAgent);
|
||||
Assert.Equal("standardname", standardAgent.Name);
|
||||
|
||||
var keyedAgent = serviceProvider.GetKeyedService<AIAgent>("keyed");
|
||||
Assert.NotNull(keyedAgent);
|
||||
Assert.Equal("keyedname", keyedAgent.Name);
|
||||
|
||||
var nonExisting = serviceProvider.GetKeyedService<AIAgent>("random-non-existing!!!");
|
||||
Assert.Null(nonExisting);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an agent registered with a different key than its name can be resolved by key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_AgentRegisteredWithDifferentKey_CanBeResolvedByKey()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
const string AgentName = "actual-agent-name";
|
||||
const string RegistrationKey = "different-key";
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var agent = new ChatClientAgent(mockChatClient.Object, "Test", AgentName);
|
||||
|
||||
services.AddKeyedSingleton<AIAgent>(RegistrationKey, agent);
|
||||
services.AddDevUI();
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
// Act
|
||||
var resolvedAgent = serviceProvider.GetKeyedService<AIAgent>(RegistrationKey);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resolvedAgent);
|
||||
// The resolved agent should have the agent's name, not the registration key
|
||||
Assert.Equal(AgentName, resolvedAgent.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an agent registered with a different key than its name can be resolved by key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_Keyed_AndStandard_BothCanBeResolved()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var defaultAgent = new ChatClientAgent(mockChatClient.Object, "default", "default");
|
||||
var keyedAgent = new ChatClientAgent(mockChatClient.Object, "keyed", "keyed");
|
||||
|
||||
services.AddSingleton<AIAgent>(defaultAgent);
|
||||
services.AddKeyedSingleton<AIAgent>("keyed-registration", keyedAgent);
|
||||
services.AddDevUI();
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
var resolvedKeyedAgent = serviceProvider.GetKeyedService<AIAgent>("keyed-registration");
|
||||
Assert.NotNull(resolvedKeyedAgent);
|
||||
Assert.Equal("keyed", resolvedKeyedAgent.Name);
|
||||
|
||||
// resolving default agent based on its name, not on the registration-key
|
||||
var resolvedDefaultAgent = serviceProvider.GetKeyedService<AIAgent>("default");
|
||||
Assert.NotNull(resolvedDefaultAgent);
|
||||
Assert.Equal("default", resolvedDefaultAgent.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the DevUI fallback handler error message includes helpful information.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_InvalidResolution_ErrorMessageIsInformative()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddDevUI();
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
const string InvalidKey = "invalid-key-name";
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => serviceProvider.GetRequiredKeyedService<AIAgent>(InvalidKey));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.DevUI.Entities;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.UnitTests;
|
||||
|
||||
public class DevUIIntegrationTests
|
||||
{
|
||||
private sealed class NoOpExecutor(string id) : Executor(id)
|
||||
{
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
|
||||
routeBuilder.AddHandler<object>(
|
||||
(msg, ctx) => ctx.SendMessageAsync(msg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestServerWithDevUI_ResolvesRequestToWorkflow_ByKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
|
||||
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("registration-key", agent);
|
||||
builder.Services.AddDevUI();
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
|
||||
await app.StartAsync();
|
||||
|
||||
// Act
|
||||
var resolvedAgent = app.Services.GetKeyedService<AIAgent>("registration-key");
|
||||
var client = app.GetTestClient();
|
||||
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
|
||||
|
||||
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
|
||||
Assert.NotNull(discoveryResponse);
|
||||
Assert.Single(discoveryResponse.Entities);
|
||||
Assert.Equal("agent-name", discoveryResponse.Entities[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestServerWithDevUI_ResolvesMultipleAIAgents_ByKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-one");
|
||||
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-two");
|
||||
var agent3 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-three");
|
||||
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agent1);
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agent2);
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("key-3", agent3);
|
||||
builder.Services.AddDevUI();
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
|
||||
await app.StartAsync();
|
||||
|
||||
// Act
|
||||
var client = app.GetTestClient();
|
||||
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
|
||||
|
||||
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(discoveryResponse);
|
||||
Assert.Equal(3, discoveryResponse.Entities.Count);
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-one" && e.Type == "agent");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-two" && e.Type == "agent");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-three" && e.Type == "agent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestServerWithDevUI_ResolvesAIAgents_WithKeyedAndDefaultRegistrationAsync()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var agentKeyed1 = new ChatClientAgent(mockChatClient.Object, "Test", "keyed-agent-one");
|
||||
var agentKeyed2 = new ChatClientAgent(mockChatClient.Object, "Test", "keyed-agent-two");
|
||||
var agentDefault = new ChatClientAgent(mockChatClient.Object, "Test", "default-agent");
|
||||
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agentKeyed1);
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agentKeyed2);
|
||||
builder.Services.AddSingleton<AIAgent>(agentDefault);
|
||||
builder.Services.AddDevUI();
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
|
||||
await app.StartAsync();
|
||||
|
||||
// Act
|
||||
var client = app.GetTestClient();
|
||||
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
|
||||
|
||||
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(discoveryResponse);
|
||||
Assert.Equal(3, discoveryResponse.Entities.Count);
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-agent-one" && e.Type == "agent");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-agent-two" && e.Type == "agent");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-agent" && e.Type == "agent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestServerWithDevUI_ResolvesMultipleWorkflows_ByKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var workflow1 = new WorkflowBuilder("executor-1")
|
||||
.WithName("workflow-one")
|
||||
.WithDescription("First workflow")
|
||||
.BindExecutor(new NoOpExecutor("executor-1"))
|
||||
.Build();
|
||||
|
||||
var workflow2 = new WorkflowBuilder("executor-2")
|
||||
.WithName("workflow-two")
|
||||
.WithDescription("Second workflow")
|
||||
.BindExecutor(new NoOpExecutor("executor-2"))
|
||||
.Build();
|
||||
|
||||
var workflow3 = new WorkflowBuilder("executor-3")
|
||||
.WithName("workflow-three")
|
||||
.WithDescription("Third workflow")
|
||||
.BindExecutor(new NoOpExecutor("executor-3"))
|
||||
.Build();
|
||||
|
||||
builder.Services.AddKeyedSingleton("key-1", workflow1);
|
||||
builder.Services.AddKeyedSingleton("key-2", workflow2);
|
||||
builder.Services.AddKeyedSingleton("key-3", workflow3);
|
||||
builder.Services.AddDevUI();
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
|
||||
await app.StartAsync();
|
||||
|
||||
// Act
|
||||
var client = app.GetTestClient();
|
||||
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
|
||||
|
||||
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(discoveryResponse);
|
||||
Assert.Equal(3, discoveryResponse.Entities.Count);
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-one" && e.Type == "workflow");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-two" && e.Type == "workflow");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-three" && e.Type == "workflow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestServerWithDevUI_ResolvesWorkflows_WithKeyedAndDefaultRegistrationAsync()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var workflowKeyed1 = new WorkflowBuilder("executor-1")
|
||||
.WithName("keyed-workflow-one")
|
||||
.BindExecutor(new NoOpExecutor("executor-1"))
|
||||
.Build();
|
||||
|
||||
var workflowKeyed2 = new WorkflowBuilder("executor-2")
|
||||
.WithName("keyed-workflow-two")
|
||||
.BindExecutor(new NoOpExecutor("executor-2"))
|
||||
.Build();
|
||||
|
||||
var workflowDefault = new WorkflowBuilder("executor-default")
|
||||
.WithName("default-workflow")
|
||||
.BindExecutor(new NoOpExecutor("executor-default"))
|
||||
.Build();
|
||||
|
||||
builder.Services.AddKeyedSingleton("key-1", workflowKeyed1);
|
||||
builder.Services.AddKeyedSingleton("key-2", workflowKeyed2);
|
||||
builder.Services.AddSingleton(workflowDefault);
|
||||
builder.Services.AddDevUI();
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
|
||||
await app.StartAsync();
|
||||
|
||||
// Act
|
||||
var client = app.GetTestClient();
|
||||
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
|
||||
|
||||
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(discoveryResponse);
|
||||
Assert.Equal(3, discoveryResponse.Entities.Count);
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-workflow-one" && e.Type == "workflow");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-workflow-two" && e.Type == "workflow");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-workflow" && e.Type == "workflow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestServerWithDevUI_ResolvesMixedAgentsAndWorkflows_AllRegistrationsAsync()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
|
||||
// Create AIAgents
|
||||
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test", "mixed-agent-one");
|
||||
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test", "mixed-agent-two");
|
||||
var agentDefault = new ChatClientAgent(mockChatClient.Object, "Test", "default-mixed-agent");
|
||||
|
||||
// Create Workflows
|
||||
var workflow1 = new WorkflowBuilder("executor-1")
|
||||
.WithName("mixed-workflow-one")
|
||||
.BindExecutor(new NoOpExecutor("executor-1"))
|
||||
.Build();
|
||||
|
||||
var workflow2 = new WorkflowBuilder("executor-2")
|
||||
.WithName("mixed-workflow-two")
|
||||
.BindExecutor(new NoOpExecutor("executor-2"))
|
||||
.Build();
|
||||
|
||||
var workflowDefault = new WorkflowBuilder("executor-default")
|
||||
.WithName("default-mixed-workflow")
|
||||
.BindExecutor(new NoOpExecutor("executor-default"))
|
||||
.Build();
|
||||
|
||||
// Register all
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("agent-key-1", agent1);
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("agent-key-2", agent2);
|
||||
builder.Services.AddSingleton<AIAgent>(agentDefault);
|
||||
builder.Services.AddKeyedSingleton("workflow-key-1", workflow1);
|
||||
builder.Services.AddKeyedSingleton("workflow-key-2", workflow2);
|
||||
builder.Services.AddSingleton(workflowDefault);
|
||||
builder.Services.AddDevUI();
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
|
||||
await app.StartAsync();
|
||||
|
||||
// Act
|
||||
var client = app.GetTestClient();
|
||||
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
|
||||
|
||||
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(discoveryResponse);
|
||||
Assert.Equal(6, discoveryResponse.Entities.Count);
|
||||
|
||||
// Verify agents
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-agent-one" && e.Type == "agent");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-agent-two" && e.Type == "agent");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-mixed-agent" && e.Type == "agent");
|
||||
|
||||
// Verify workflows
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-workflow-one" && e.Type == "workflow");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-workflow-two" && e.Type == "workflow");
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-mixed-workflow" && e.Type == "workflow");
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<IsPackable>false</IsPackable>
|
||||
<NoWarn>$(NoWarn);CA1812</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" VersionOverride="8.0.21" Condition="'$(TargetFramework)' == 'net8.0'" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' != 'net8.0'" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Microsoft.Agents.AI.DevUI.UnitTests": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:63009;http://localhost:63010"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Microsoft.Agents.AI.Hosting.A2A.UnitTests": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:52186;http://localhost:52187"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
|
||||
@@ -15,10 +16,18 @@ internal sealed class MockAgentProvider : Mock<WorkflowAgentProvider>
|
||||
{
|
||||
public IList<string> ExistingConversationIds { get; } = [];
|
||||
|
||||
public ChatMessage? TestChatMessage { get; set; }
|
||||
|
||||
public MockAgentProvider()
|
||||
{
|
||||
this.Setup(provider => provider.CreateConversationAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(() => Task.FromResult(this.CreateConversationId()));
|
||||
|
||||
this.Setup(provider => provider.GetMessageAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.FromResult(this.CreateChatMessage()));
|
||||
}
|
||||
|
||||
private string CreateConversationId()
|
||||
@@ -28,4 +37,13 @@ internal sealed class MockAgentProvider : Mock<WorkflowAgentProvider>
|
||||
|
||||
return newConversationId;
|
||||
}
|
||||
|
||||
private ChatMessage CreateChatMessage()
|
||||
{
|
||||
this.TestChatMessage = new ChatMessage(ChatRole.User, Guid.NewGuid().ToString("N"))
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
return this.TestChatMessage;
|
||||
}
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Bot.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="RetrieveConversationMessageExecutor"/>.
|
||||
/// </summary>
|
||||
public sealed class RetrieveConversationMessageExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task RetrieveMessageSuccessfullyAsync()
|
||||
{
|
||||
// Arrange, Act, Assert
|
||||
await this.ExecuteTestAsync(nameof(RetrieveMessageSuccessfullyAsync),
|
||||
"TestMessage");
|
||||
}
|
||||
|
||||
private async Task ExecuteTestAsync(
|
||||
string displayName,
|
||||
string variableName)
|
||||
{
|
||||
// Arrange
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
|
||||
RetrieveConversationMessage model = this.CreateModel(
|
||||
this.FormatDisplayName(displayName),
|
||||
FormatVariablePath(variableName),
|
||||
"TestConversationId",
|
||||
"DefaultMessageId");
|
||||
|
||||
RetrieveConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert
|
||||
ChatMessage testMessage = mockAgentProvider.TestChatMessage ?? new ChatMessage();
|
||||
VerifyModel(model, action);
|
||||
this.VerifyState(variableName, testMessage.ToRecord());
|
||||
}
|
||||
|
||||
private RetrieveConversationMessage CreateModel(
|
||||
string displayName,
|
||||
string messageVariable,
|
||||
string conversationId,
|
||||
string messageId)
|
||||
{
|
||||
RetrieveConversationMessage.Builder actionBuilder =
|
||||
new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
Message = PropertyPath.Create(messageVariable),
|
||||
ConversationId = StringExpression.Literal(conversationId),
|
||||
MessageId = StringExpression.Literal(messageId)
|
||||
};
|
||||
|
||||
return AssignParent<RetrieveConversationMessage>(actionBuilder);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@
|
||||
"OPENAI",
|
||||
"opentelemetry",
|
||||
"OTEL",
|
||||
"powerfx",
|
||||
"protos",
|
||||
"pydantic",
|
||||
"pytestmark",
|
||||
|
||||
@@ -3,6 +3,14 @@ AZURE_AI_PROJECT_ENDPOINT=""
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=""
|
||||
# Bing connection for web search (optional, used by samples with web search)
|
||||
BING_CONNECTION_ID=""
|
||||
# Azure AI Search (optional, used by AzureAISearchContextProvider samples)
|
||||
AZURE_SEARCH_ENDPOINT=""
|
||||
AZURE_SEARCH_API_KEY=""
|
||||
AZURE_SEARCH_INDEX_NAME=""
|
||||
AZURE_SEARCH_SEMANTIC_CONFIG=""
|
||||
AZURE_SEARCH_KNOWLEDGE_BASE_NAME=""
|
||||
# Note: For agentic mode Knowledge Bases, also set AZURE_OPENAI_ENDPOINT below
|
||||
# (different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs OpenAI endpoint for model calls)
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=""
|
||||
OPENAI_CHAT_MODEL_ID=""
|
||||
|
||||
+33
-1
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0b251120] - 2025-11-20
|
||||
|
||||
### Added
|
||||
|
||||
- **agent-framework-core**: Introducing support for declarative YAML spec ([#2002](https://github.com/microsoft/agent-framework/pull/2002))
|
||||
- **agent-framework-core**: Use AI Foundry evaluators for self-reflection ([#2250](https://github.com/microsoft/agent-framework/pull/2250))
|
||||
- **agent-framework-core**: Propagate `as_tool()` kwargs and add runtime context + middleware sample ([#2311](https://github.com/microsoft/agent-framework/pull/2311))
|
||||
- **agent-framework-anthropic**: Anthropic Foundry integration ([#2302](https://github.com/microsoft/agent-framework/pull/2302))
|
||||
- **samples**: M365 Agent SDK Hosting sample ([#2292](https://github.com/microsoft/agent-framework/pull/2292))
|
||||
- **samples**: Foundry Sample for A2A + SharePoint Samples ([#2313](https://github.com/microsoft/agent-framework/pull/2313))
|
||||
|
||||
### Changed
|
||||
|
||||
- **agent-framework-azurefunctions**: [BREAKING] Schema changes for Azure Functions package ([#2151](https://github.com/microsoft/agent-framework/pull/2151))
|
||||
- **agent-framework-core**: Move evaluation folders under `evaluations` ([#2355](https://github.com/microsoft/agent-framework/pull/2355))
|
||||
- **agent-framework-core**: Move red teaming files to their own folder ([#2333](https://github.com/microsoft/agent-framework/pull/2333))
|
||||
- **agent-framework-core**: "fix all" task now single source of truth ([#2303](https://github.com/microsoft/agent-framework/pull/2303))
|
||||
- **agent-framework-core**: Improve and clean up exception handling ([#2337](https://github.com/microsoft/agent-framework/pull/2337), [#2319](https://github.com/microsoft/agent-framework/pull/2319))
|
||||
- **agent-framework-core**: Clean up imports ([#2318](https://github.com/microsoft/agent-framework/pull/2318))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **agent-framework-azure-ai**: Fix for Azure AI client ([#2358](https://github.com/microsoft/agent-framework/pull/2358))
|
||||
- **agent-framework-core**: Fix tool execution bleed-over in aiohttp/Bot Framework scenarios ([#2314](https://github.com/microsoft/agent-framework/pull/2314))
|
||||
- **agent-framework-core**: `@ai_function` now correctly handles `self` parameter ([#2266](https://github.com/microsoft/agent-framework/pull/2266))
|
||||
- **agent-framework-core**: Resolve string annotations in `FunctionExecutor` ([#2308](https://github.com/microsoft/agent-framework/pull/2308))
|
||||
- **agent-framework-core**: Langfuse observability captures ChatAgent system instructions ([#2316](https://github.com/microsoft/agent-framework/pull/2316))
|
||||
- **agent-framework-core**: Incomplete URL substring sanitization fix ([#2274](https://github.com/microsoft/agent-framework/pull/2274))
|
||||
- **observability**: Handle datetime serialization in tool results ([#2248](https://github.com/microsoft/agent-framework/pull/2248))
|
||||
|
||||
## [1.0.0b251117] - 2025-11-17
|
||||
|
||||
### Fixed
|
||||
@@ -260,7 +290,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251114...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251120...HEAD
|
||||
[1.0.0b251120]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251117...python-1.0.0b251120
|
||||
[1.0.0b251117]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251114...python-1.0.0b251117
|
||||
[1.0.0b251114]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251112.post1...python-1.0.0b251114
|
||||
[1.0.0b251112.post1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251112...python-1.0.0b251112.post1
|
||||
[1.0.0b251112]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...python-1.0.0b251112
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251114"
|
||||
version = "1.0.0b251120"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -16,7 +16,7 @@ pip install agent-framework-ag-ui
|
||||
from fastapi import FastAPI
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||
|
||||
# Create your agent
|
||||
agent = ChatAgent(
|
||||
@@ -41,7 +41,7 @@ add_agent_framework_fastapi_endpoint(app, agent, "/")
|
||||
```python
|
||||
import asyncio
|
||||
from agent_framework import TextContent
|
||||
from agent_framework_ag_ui import AGUIChatClient
|
||||
from agent_framework.ag_ui import AGUIChatClient
|
||||
|
||||
async def main():
|
||||
async with AGUIChatClient(endpoint="http://localhost:8000/") as client:
|
||||
|
||||
@@ -91,4 +91,4 @@ def add_agent_framework_fastapi_endpoint(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in agent endpoint: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
return {"error": "An internal error has occurred."}
|
||||
|
||||
@@ -18,7 +18,7 @@ All example agents are factory functions that accept any `ChatClientProtocol`-co
|
||||
from fastapi import FastAPI
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent
|
||||
|
||||
app = FastAPI()
|
||||
@@ -40,7 +40,7 @@ add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weathe
|
||||
from fastapi import FastAPI
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||
|
||||
# Create your agent
|
||||
agent = ChatAgent(
|
||||
@@ -136,7 +136,7 @@ The server exposes endpoints at:
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework_ag_ui_examples.agents import (
|
||||
simple_agent,
|
||||
weather_agent,
|
||||
@@ -188,8 +188,8 @@ You can create your own agent factories following the same pattern as the exampl
|
||||
|
||||
```python
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework import ChatClientProtocol
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
@ai_function
|
||||
def my_tool(param: str) -> str:
|
||||
|
||||
+2
-4
@@ -2,10 +2,8 @@
|
||||
|
||||
"""Example agent demonstrating predictive state updates with document writing."""
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
|
||||
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
|
||||
|
||||
|
||||
@ai_function
|
||||
|
||||
+1
-2
@@ -4,8 +4,7 @@
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy
|
||||
|
||||
|
||||
class SkillLevel(str, Enum):
|
||||
"""The skill level required for the recipe."""
|
||||
|
||||
+2
-4
@@ -4,10 +4,8 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
|
||||
@ai_function
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
"""Simple agentic chat example (Feature 1: Agentic Chat)."""
|
||||
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
from agent_framework import ChatAgent, ChatClientProtocol
|
||||
|
||||
|
||||
def simple_agent(chat_client: ChatClientProtocol) -> ChatAgent:
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
"""Example agent demonstrating human-in-the-loop with function approvals."""
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy
|
||||
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy
|
||||
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
|
||||
@@ -18,12 +18,10 @@ from ag_ui.core import (
|
||||
TextMessageStartEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
|
||||
class StepStatus(str, Enum):
|
||||
"""Status of a task step."""
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AIFunction, ChatAgent
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework import AIFunction, ChatAgent, ChatClientProtocol
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
# Declaration-only tools (func=None) - actual rendering happens on the client side
|
||||
generate_haiku = AIFunction[Any, str](
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
|
||||
|
||||
|
||||
@ai_function
|
||||
|
||||
+1
-2
@@ -2,11 +2,10 @@
|
||||
|
||||
"""Backend tool rendering endpoint."""
|
||||
|
||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from fastapi import FastAPI
|
||||
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
|
||||
from ...agents.weather_agent import weather_agent
|
||||
|
||||
|
||||
|
||||
@@ -6,12 +6,11 @@ import logging
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
|
||||
from ..agents.document_writer_agent import document_writer_agent
|
||||
from ..agents.human_in_the_loop_agent import human_in_the_loop_agent
|
||||
from ..agents.recipe_agent import recipe_agent
|
||||
|
||||
@@ -10,7 +10,7 @@ standard chat interface.
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework_ag_ui import AGUIChatClient
|
||||
from agent_framework.ag_ui import AGUIChatClient
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
@@ -13,8 +13,7 @@ import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import ai_function
|
||||
|
||||
from agent_framework_ag_ui import AGUIChatClient
|
||||
from agent_framework.ag_ui import AGUIChatClient
|
||||
|
||||
|
||||
@ai_function
|
||||
|
||||
@@ -23,8 +23,7 @@ import logging
|
||||
import os
|
||||
|
||||
from agent_framework import ChatAgent, FunctionCallContent, FunctionResultContent, TextContent, ai_function
|
||||
|
||||
from agent_framework_ag_ui import AGUIChatClient
|
||||
from agent_framework.ag_ui import AGUIChatClient
|
||||
|
||||
# Enable debug logging
|
||||
logging.basicConfig(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b251117"
|
||||
version = "1.0.0b251120"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
@@ -11,7 +11,7 @@ from agent_framework._types import ChatResponseUpdate
|
||||
|
||||
async def test_agent_initialization_basic():
|
||||
"""Test basic agent initialization without state schema."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -28,7 +28,7 @@ async def test_agent_initialization_basic():
|
||||
|
||||
async def test_agent_initialization_with_state_schema():
|
||||
"""Test agent initialization with state_schema."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -43,7 +43,7 @@ async def test_agent_initialization_with_state_schema():
|
||||
|
||||
async def test_agent_initialization_with_predict_state_config():
|
||||
"""Test agent initialization with predict_state_config."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -58,7 +58,7 @@ async def test_agent_initialization_with_predict_state_config():
|
||||
|
||||
async def test_run_started_event_emission():
|
||||
"""Test RunStartedEvent is emitted at start of run."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -81,7 +81,7 @@ async def test_run_started_event_emission():
|
||||
|
||||
async def test_predict_state_custom_event_emission():
|
||||
"""Test PredictState CustomEvent is emitted when predict_state_config is present."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -112,7 +112,7 @@ async def test_predict_state_custom_event_emission():
|
||||
|
||||
async def test_initial_state_snapshot_with_schema():
|
||||
"""Test initial StateSnapshotEvent emission when state_schema present."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -141,7 +141,7 @@ async def test_initial_state_snapshot_with_schema():
|
||||
|
||||
async def test_state_initialization_object_type():
|
||||
"""Test state initialization with object type in schema."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -167,7 +167,7 @@ async def test_state_initialization_object_type():
|
||||
|
||||
async def test_state_initialization_array_type():
|
||||
"""Test state initialization with array type in schema."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -193,7 +193,7 @@ async def test_state_initialization_array_type():
|
||||
|
||||
async def test_run_finished_event_emission():
|
||||
"""Test RunFinishedEvent is emitted at end of run."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -214,7 +214,7 @@ async def test_run_finished_event_emission():
|
||||
|
||||
async def test_tool_result_confirm_changes_accepted():
|
||||
"""Test confirm_changes tool result handling when accepted."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -260,7 +260,7 @@ async def test_tool_result_confirm_changes_accepted():
|
||||
|
||||
async def test_tool_result_confirm_changes_rejected():
|
||||
"""Test confirm_changes tool result handling when rejected."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -293,7 +293,7 @@ async def test_tool_result_confirm_changes_rejected():
|
||||
|
||||
async def test_tool_result_function_approval_accepted():
|
||||
"""Test function approval tool result when steps are accepted."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -338,7 +338,7 @@ async def test_tool_result_function_approval_accepted():
|
||||
|
||||
async def test_tool_result_function_approval_rejected():
|
||||
"""Test function approval tool result when rejected."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -374,7 +374,7 @@ async def test_tool_result_function_approval_rejected():
|
||||
|
||||
async def test_thread_metadata_tracking():
|
||||
"""Test that thread metadata includes ag_ui_thread_id and ag_ui_run_id."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
thread_metadata = {}
|
||||
|
||||
@@ -405,7 +405,7 @@ async def test_thread_metadata_tracking():
|
||||
|
||||
async def test_state_context_injection():
|
||||
"""Test that current state is injected into thread metadata."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
thread_metadata = {}
|
||||
|
||||
@@ -436,7 +436,7 @@ async def test_state_context_injection():
|
||||
|
||||
async def test_no_messages_provided():
|
||||
"""Test handling when no messages are provided."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -459,7 +459,7 @@ async def test_no_messages_provided():
|
||||
|
||||
async def test_message_end_event_emission():
|
||||
"""Test TextMessageEndEvent is emitted for assistant messages."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -486,7 +486,7 @@ async def test_message_end_event_emission():
|
||||
|
||||
async def test_error_handling_with_exception():
|
||||
"""Test that exceptions during agent execution are re-raised."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class FailingChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -506,7 +506,7 @@ async def test_error_handling_with_exception():
|
||||
|
||||
async def test_json_decode_error_in_tool_result():
|
||||
"""Test handling of orphaned tool result - should be sanitized out."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -543,7 +543,7 @@ async def test_json_decode_error_in_tool_result():
|
||||
|
||||
async def test_suppressed_summary_with_document_state():
|
||||
"""Test suppressed summary uses document state for confirmation message."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
|
||||
@@ -154,7 +154,7 @@ async def test_endpoint_error_handling():
|
||||
assert response.status_code == 200
|
||||
content = json.loads(response.content)
|
||||
assert "error" in content
|
||||
assert "Expecting value" in content["error"]
|
||||
assert content["error"] == "An internal error has occurred."
|
||||
|
||||
|
||||
async def test_endpoint_multiple_paths():
|
||||
|
||||
@@ -32,7 +32,7 @@ class GenericOutput(BaseModel):
|
||||
|
||||
async def test_structured_output_with_recipe():
|
||||
"""Test structured output processing with recipe state."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -70,7 +70,7 @@ async def test_structured_output_with_recipe():
|
||||
|
||||
async def test_structured_output_with_steps():
|
||||
"""Test structured output processing with steps state."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -109,7 +109,7 @@ async def test_structured_output_with_steps():
|
||||
|
||||
async def test_structured_output_with_no_schema_match():
|
||||
"""Test structured output when response fields don't match state_schema keys."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -138,7 +138,7 @@ async def test_structured_output_with_no_schema_match():
|
||||
|
||||
async def test_structured_output_without_schema():
|
||||
"""Test structured output without state_schema treats all fields as state."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class DataOutput(BaseModel):
|
||||
"""Output with data and info fields."""
|
||||
@@ -175,7 +175,7 @@ async def test_structured_output_without_schema():
|
||||
|
||||
async def test_no_structured_output_when_no_response_format():
|
||||
"""Test that structured output path is skipped when no response_format."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -200,7 +200,7 @@ async def test_no_structured_output_when_no_response_format():
|
||||
|
||||
async def test_structured_output_with_message_field():
|
||||
"""Test structured output that includes a message field."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
@@ -234,7 +234,7 @@ async def test_structured_output_with_message_field():
|
||||
|
||||
async def test_empty_updates_no_structured_processing():
|
||||
"""Test that empty updates don't trigger structured output processing."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,23 @@
|
||||
# Get Started with Microsoft Agent Framework Azure AI Search
|
||||
|
||||
Please install this package via pip:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-aisearch --pre
|
||||
```
|
||||
|
||||
## Azure AI Search Integration
|
||||
|
||||
The Azure AI Search integration provides context providers for RAG (Retrieval Augmented Generation) capabilities with two modes:
|
||||
|
||||
- **Semantic Mode**: Fast hybrid search (vector + keyword) with semantic ranking
|
||||
- **Agentic Mode**: Multi-hop reasoning using Knowledge Bases for complex queries
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
See the [Azure AI Search context provider examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/azure_ai/) which demonstrate:
|
||||
|
||||
- Semantic search with hybrid (vector + keyword) queries
|
||||
- Agentic mode with Knowledge Bases for complex multi-hop reasoning
|
||||
- Environment variable configuration with Settings class
|
||||
- API key and managed identity authentication
|
||||
@@ -0,0 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._search_provider import AzureAISearchContextProvider, AzureAISearchSettings
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"AzureAISearchContextProvider",
|
||||
"AzureAISearchSettings",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,914 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Azure AI Search Context Provider for Agent Framework.
|
||||
|
||||
This module provides context providers for Azure AI Search integration with two modes:
|
||||
- Agentic: Recommended for most scenarios. Uses Knowledge Bases for query planning and
|
||||
multi-hop reasoning. Slightly slower with more token consumption, but more accurate.
|
||||
- Semantic: Fast hybrid search (vector + keyword) with semantic ranker. Best for simple
|
||||
queries where speed is critical.
|
||||
|
||||
See: https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720
|
||||
"""
|
||||
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable, MutableSequence
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
||||
|
||||
from agent_framework import ChatMessage, Context, ContextProvider, Role
|
||||
from agent_framework._logging import get_logger
|
||||
from agent_framework._pydantic import AFBaseSettings
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.search.documents.aio import SearchClient
|
||||
from azure.search.documents.indexes.aio import SearchIndexClient
|
||||
from azure.search.documents.indexes.models import (
|
||||
AzureOpenAIVectorizerParameters,
|
||||
KnowledgeBase,
|
||||
KnowledgeBaseAzureOpenAIModel,
|
||||
KnowledgeRetrievalLowReasoningEffort,
|
||||
KnowledgeRetrievalMediumReasoningEffort,
|
||||
KnowledgeRetrievalMinimalReasoningEffort,
|
||||
KnowledgeRetrievalOutputMode,
|
||||
KnowledgeRetrievalReasoningEffort,
|
||||
KnowledgeSourceReference,
|
||||
SearchIndexKnowledgeSource,
|
||||
SearchIndexKnowledgeSourceParameters,
|
||||
)
|
||||
from azure.search.documents.models import (
|
||||
QueryCaptionType,
|
||||
QueryType,
|
||||
VectorizableTextQuery,
|
||||
VectorizedQuery,
|
||||
)
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
# Type checking imports for optional agentic mode dependencies
|
||||
if TYPE_CHECKING:
|
||||
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeBaseMessage,
|
||||
KnowledgeBaseMessageTextContent,
|
||||
KnowledgeBaseRetrievalRequest,
|
||||
KnowledgeRetrievalIntent,
|
||||
KnowledgeRetrievalSemanticIntent,
|
||||
)
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort,
|
||||
)
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort,
|
||||
)
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort,
|
||||
)
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeRetrievalOutputMode as KBRetrievalOutputMode,
|
||||
)
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort,
|
||||
)
|
||||
|
||||
# Runtime imports for agentic mode (optional dependency)
|
||||
try:
|
||||
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeBaseMessage,
|
||||
KnowledgeBaseMessageTextContent,
|
||||
KnowledgeBaseRetrievalRequest,
|
||||
KnowledgeRetrievalIntent,
|
||||
KnowledgeRetrievalSemanticIntent,
|
||||
)
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort,
|
||||
)
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort,
|
||||
)
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort,
|
||||
)
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeRetrievalOutputMode as KBRetrievalOutputMode,
|
||||
)
|
||||
from azure.search.documents.knowledgebases.models import (
|
||||
KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort,
|
||||
)
|
||||
|
||||
_agentic_retrieval_available = True
|
||||
except ImportError:
|
||||
_agentic_retrieval_available = False
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore[import] # pragma: no cover
|
||||
|
||||
# Module-level constants
|
||||
logger = get_logger("agent_framework.azure")
|
||||
_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10
|
||||
|
||||
|
||||
class AzureAISearchSettings(AFBaseSettings):
|
||||
"""Settings for Azure AI Search Context Provider with auto-loading from environment.
|
||||
|
||||
The settings are first loaded from environment variables with the prefix 'AZURE_SEARCH_'.
|
||||
If the environment variables are not found, the settings can be loaded from a .env file.
|
||||
|
||||
Keyword Args:
|
||||
endpoint: Azure AI Search endpoint URL.
|
||||
Can be set via environment variable AZURE_SEARCH_ENDPOINT.
|
||||
index_name: Name of the search index.
|
||||
Can be set via environment variable AZURE_SEARCH_INDEX_NAME.
|
||||
api_key: API key for authentication (optional, use managed identity if not provided).
|
||||
Can be set via environment variable AZURE_SEARCH_API_KEY.
|
||||
env_file_path: If provided, the .env settings are read from this file path location.
|
||||
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_aisearch import AzureAISearchSettings
|
||||
|
||||
# Using environment variables
|
||||
# Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net
|
||||
# Set AZURE_SEARCH_INDEX_NAME=my-index
|
||||
settings = AzureAISearchSettings()
|
||||
|
||||
# Or passing parameters directly
|
||||
settings = AzureAISearchSettings(
|
||||
endpoint="https://mysearch.search.windows.net",
|
||||
index_name="my-index",
|
||||
)
|
||||
|
||||
# Or loading from a .env file
|
||||
settings = AzureAISearchSettings(env_file_path="path/to/.env")
|
||||
"""
|
||||
|
||||
env_prefix: ClassVar[str] = "AZURE_SEARCH_"
|
||||
|
||||
endpoint: str | None = None
|
||||
index_name: str | None = None
|
||||
api_key: SecretStr | None = None
|
||||
|
||||
|
||||
class AzureAISearchContextProvider(ContextProvider):
|
||||
"""Azure AI Search Context Provider with hybrid search and semantic ranking.
|
||||
|
||||
This provider retrieves relevant documents from Azure AI Search to provide context
|
||||
to the AI agent. It supports two modes:
|
||||
|
||||
- **agentic**: Recommended for most scenarios. Uses Knowledge Bases for query planning
|
||||
and multi-hop reasoning. Slightly slower with more token consumption, but provides
|
||||
more accurate results (up to 36% improvement in response relevance).
|
||||
- **semantic** (default): Fast hybrid search combining vector and keyword search
|
||||
with semantic reranking. Best for simple queries where speed is critical.
|
||||
|
||||
Examples:
|
||||
Using environment variables (recommended):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_aisearch import AzureAISearchContextProvider
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
|
||||
# Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_INDEX_NAME in environment
|
||||
search_provider = AzureAISearchContextProvider(credential=DefaultAzureCredential())
|
||||
|
||||
Semantic hybrid search with API key:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Direct API key string
|
||||
search_provider = AzureAISearchContextProvider(
|
||||
endpoint="https://mysearch.search.windows.net",
|
||||
index_name="my-index",
|
||||
api_key="my-api-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
Loading from .env file:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Load settings from a .env file
|
||||
search_provider = AzureAISearchContextProvider(
|
||||
credential=DefaultAzureCredential(), env_file_path="path/to/.env"
|
||||
)
|
||||
|
||||
Agentic retrieval for complex queries:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Use agentic mode for multi-hop reasoning
|
||||
# Note: azure_openai_resource_url is the OpenAI endpoint for Knowledge Base model calls,
|
||||
# which is different from azure_ai_project_endpoint (the AI Foundry project endpoint)
|
||||
search_provider = AzureAISearchContextProvider(
|
||||
endpoint="https://mysearch.search.windows.net",
|
||||
index_name="my-index",
|
||||
credential=DefaultAzureCredential(),
|
||||
mode="agentic",
|
||||
azure_openai_resource_url="https://myresource.openai.azure.com",
|
||||
model_deployment_name="gpt-4o",
|
||||
knowledge_base_name="my-knowledge-base",
|
||||
)
|
||||
"""
|
||||
|
||||
_DEFAULT_SEARCH_CONTEXT_PROMPT = "Use the following context to answer the question:"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str | None = None,
|
||||
index_name: str | None = None,
|
||||
api_key: str | AzureKeyCredential | None = None,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
*,
|
||||
mode: Literal["semantic", "agentic"] = "semantic",
|
||||
top_k: int = 5,
|
||||
semantic_configuration_name: str | None = None,
|
||||
vector_field_name: str | None = None,
|
||||
embedding_function: Callable[[str], Awaitable[list[float]]] | None = None,
|
||||
context_prompt: str | None = None,
|
||||
# Agentic mode parameters (Knowledge Base)
|
||||
azure_ai_project_endpoint: str | None = None,
|
||||
azure_openai_resource_url: str | None = None,
|
||||
model_deployment_name: str | None = None,
|
||||
model_name: str | None = None,
|
||||
knowledge_base_name: str | None = None,
|
||||
retrieval_instructions: str | None = None,
|
||||
azure_openai_api_key: str | None = None,
|
||||
knowledge_base_output_mode: Literal["extractive_data", "answer_synthesis"] = "extractive_data",
|
||||
retrieval_reasoning_effort: Literal["minimal", "medium", "low"] = "minimal",
|
||||
agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize Azure AI Search Context Provider.
|
||||
|
||||
Args:
|
||||
endpoint: Azure AI Search endpoint URL.
|
||||
Can also be set via environment variable AZURE_SEARCH_ENDPOINT.
|
||||
index_name: Name of the search index to query.
|
||||
Can also be set via environment variable AZURE_SEARCH_INDEX_NAME.
|
||||
api_key: API key for authentication (string or AzureKeyCredential).
|
||||
Can also be set via environment variable AZURE_SEARCH_API_KEY.
|
||||
credential: AsyncTokenCredential for managed identity authentication.
|
||||
Use this for Entra ID authentication instead of api_key.
|
||||
mode: Search mode - "semantic" for hybrid search with semantic ranking (fast)
|
||||
or "agentic" for multi-hop reasoning (slower). Default: "semantic".
|
||||
top_k: Maximum number of documents to retrieve. Only applies to semantic mode.
|
||||
In agentic mode, the server-side Knowledge Base determines retrieval based on
|
||||
query complexity and reasoning effort. Default: 5.
|
||||
semantic_configuration_name: Name of semantic configuration in the index.
|
||||
Required for semantic ranking. If None, uses index default.
|
||||
vector_field_name: Name of the vector field in the index for hybrid search.
|
||||
Required if using vector search. Default: None (keyword search only).
|
||||
embedding_function: Async function to generate embeddings for vector search.
|
||||
Signature: async def embed(text: str) -> list[float]
|
||||
Required if vector_field_name is specified and no server-side vectorization.
|
||||
context_prompt: Custom prompt to prepend to retrieved context.
|
||||
Default: "Use the following context to answer the question:"
|
||||
azure_ai_project_endpoint: Azure AI Foundry project endpoint URL.
|
||||
This is NOT the same as azure_openai_resource_url - the project endpoint is used
|
||||
for Azure AI Foundry services, while the OpenAI endpoint is used by the Knowledge
|
||||
Base to call the model for query planning. Required for agentic mode.
|
||||
Example: "https://myproject.services.ai.azure.com/api/projects/myproject"
|
||||
azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base model calls.
|
||||
This is the OpenAI endpoint used by the Knowledge Base to call the LLM for
|
||||
query planning and reasoning. This is separate from the project endpoint because
|
||||
the Knowledge Base directly calls Azure OpenAI for its internal operations.
|
||||
Required for agentic mode. Example: "https://myresource.openai.azure.com"
|
||||
model_deployment_name: Model deployment name in Azure OpenAI for Knowledge Base.
|
||||
This is the deployment name the Knowledge Base uses to call the LLM.
|
||||
Required for agentic mode.
|
||||
model_name: The underlying model name (e.g., "gpt-4o", "gpt-4o-mini").
|
||||
If not provided, defaults to model_deployment_name. Used for Knowledge Base configuration.
|
||||
knowledge_base_name: Name for the Knowledge Base. Required for agentic mode.
|
||||
retrieval_instructions: Custom instructions for the Knowledge Base's
|
||||
retrieval planning. Only used in agentic mode.
|
||||
azure_openai_api_key: Azure OpenAI API key for Knowledge Base to call the model.
|
||||
Only needed when using API key authentication instead of managed identity.
|
||||
knowledge_base_output_mode: Output mode for Knowledge Base retrieval. Only used in agentic mode.
|
||||
"extractive_data": Returns raw chunks without synthesis (default, recommended for agent integration).
|
||||
"answer_synthesis": Returns synthesized answer from the LLM.
|
||||
Some knowledge sources require answer_synthesis mode. Default: "extractive_data".
|
||||
retrieval_reasoning_effort: Reasoning effort for Knowledge Base query planning. Only used in agentic mode.
|
||||
"minimal": Fastest, basic query planning.
|
||||
"medium": Moderate reasoning with some query decomposition.
|
||||
"low": Lower reasoning effort than medium.
|
||||
Default: "minimal".
|
||||
agentic_message_history_count: Number of recent messages from conversation history to send to
|
||||
the Knowledge Base. This context helps with query planning in agentic mode, allowing the
|
||||
Knowledge Base to understand the conversation flow and generate better retrieval queries.
|
||||
There is no technical limit - adjust based on your use case. Default: 10.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_aisearch import AzureAISearchContextProvider
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
|
||||
# Using environment variables
|
||||
# Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net
|
||||
# Set AZURE_SEARCH_INDEX_NAME=my-index
|
||||
credential = DefaultAzureCredential()
|
||||
provider = AzureAISearchContextProvider(credential=credential)
|
||||
|
||||
# Or passing parameters directly
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://mysearch.search.windows.net",
|
||||
index_name="my-index",
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
# Or loading from a .env file
|
||||
provider = AzureAISearchContextProvider(credential=credential, env_file_path="path/to/.env")
|
||||
"""
|
||||
# Load settings from environment/file
|
||||
try:
|
||||
settings = AzureAISearchSettings(
|
||||
endpoint=endpoint,
|
||||
index_name=index_name,
|
||||
api_key=api_key if isinstance(api_key, str) else None,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
except ValidationError as ex:
|
||||
raise ServiceInitializationError("Failed to create Azure AI Search settings.", ex) from ex
|
||||
|
||||
# Validate required parameters
|
||||
if not settings.endpoint:
|
||||
raise ServiceInitializationError(
|
||||
"Azure AI Search endpoint is required. Set via 'endpoint' parameter "
|
||||
"or 'AZURE_SEARCH_ENDPOINT' environment variable."
|
||||
)
|
||||
if not settings.index_name:
|
||||
raise ServiceInitializationError(
|
||||
"Azure AI Search index name is required. Set via 'index_name' parameter "
|
||||
"or 'AZURE_SEARCH_INDEX_NAME' environment variable."
|
||||
)
|
||||
|
||||
# Determine the credential to use
|
||||
resolved_credential: AzureKeyCredential | AsyncTokenCredential
|
||||
if credential:
|
||||
# AsyncTokenCredential takes precedence
|
||||
resolved_credential = credential
|
||||
elif isinstance(api_key, AzureKeyCredential):
|
||||
resolved_credential = api_key
|
||||
elif settings.api_key:
|
||||
resolved_credential = AzureKeyCredential(settings.api_key.get_secret_value())
|
||||
else:
|
||||
raise ServiceInitializationError(
|
||||
"Azure credential is required. Provide 'api_key' or 'credential' parameter "
|
||||
"or set 'AZURE_SEARCH_API_KEY' environment variable."
|
||||
)
|
||||
|
||||
self.endpoint = settings.endpoint
|
||||
self.index_name = settings.index_name
|
||||
self.credential = resolved_credential
|
||||
self.mode = mode
|
||||
self.top_k = top_k
|
||||
self.semantic_configuration_name = semantic_configuration_name
|
||||
self.vector_field_name = vector_field_name
|
||||
self.embedding_function = embedding_function
|
||||
self.context_prompt = context_prompt or self._DEFAULT_SEARCH_CONTEXT_PROMPT
|
||||
|
||||
# Agentic mode parameters (Knowledge Base)
|
||||
self.azure_openai_resource_url = azure_openai_resource_url
|
||||
self.azure_openai_deployment_name = model_deployment_name
|
||||
# If model_name not provided, default to deployment name
|
||||
self.model_name = model_name or model_deployment_name
|
||||
self.knowledge_base_name = knowledge_base_name
|
||||
self.retrieval_instructions = retrieval_instructions
|
||||
self.azure_openai_api_key = azure_openai_api_key
|
||||
self.azure_ai_project_endpoint = azure_ai_project_endpoint
|
||||
self.knowledge_base_output_mode = knowledge_base_output_mode
|
||||
self.retrieval_reasoning_effort = retrieval_reasoning_effort
|
||||
self.agentic_message_history_count = agentic_message_history_count
|
||||
|
||||
# Auto-discover vector field if not specified
|
||||
self._auto_discovered_vector_field = False
|
||||
self._use_vectorizable_query = False # Will be set to True if server-side vectorization detected
|
||||
if not vector_field_name and mode == "semantic":
|
||||
# Attempt to auto-discover vector field from index schema
|
||||
# This will be done lazily on first search to avoid blocking initialization
|
||||
pass
|
||||
|
||||
# Validation
|
||||
if vector_field_name and not embedding_function:
|
||||
raise ValueError("embedding_function is required when vector_field_name is specified")
|
||||
|
||||
if mode == "agentic":
|
||||
if not _agentic_retrieval_available:
|
||||
raise ImportError(
|
||||
"Agentic retrieval requires azure-search-documents >= 11.7.0b1 with Knowledge Base support. "
|
||||
"Please upgrade: pip install azure-search-documents>=11.7.0b1"
|
||||
)
|
||||
if not self.azure_openai_resource_url:
|
||||
raise ValueError(
|
||||
"azure_openai_resource_url is required for agentic mode. "
|
||||
"This should be your Azure OpenAI endpoint (e.g., 'https://myresource.openai.azure.com')"
|
||||
)
|
||||
if not self.azure_openai_deployment_name:
|
||||
raise ValueError("model_deployment_name is required for agentic mode")
|
||||
if not knowledge_base_name:
|
||||
raise ValueError("knowledge_base_name is required for agentic mode")
|
||||
|
||||
# Create search client for semantic mode
|
||||
self._search_client = SearchClient(
|
||||
endpoint=self.endpoint,
|
||||
index_name=self.index_name,
|
||||
credential=self.credential,
|
||||
)
|
||||
|
||||
# Create index client and retrieval client for agentic mode (Knowledge Base)
|
||||
self._index_client: SearchIndexClient | None = None
|
||||
self._retrieval_client: KnowledgeBaseRetrievalClient | None = None
|
||||
if mode == "agentic":
|
||||
self._index_client = SearchIndexClient(
|
||||
endpoint=self.endpoint,
|
||||
credential=self.credential,
|
||||
)
|
||||
# Retrieval client will be created after Knowledge Base initialization
|
||||
|
||||
self._knowledge_base_initialized = False
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: Any,
|
||||
) -> None:
|
||||
"""Async context manager exit - cleanup clients.
|
||||
|
||||
Args:
|
||||
exc_type: Exception type if an error occurred.
|
||||
exc_val: Exception value if an error occurred.
|
||||
exc_tb: Exception traceback if an error occurred.
|
||||
"""
|
||||
# Close retrieval client if it was created
|
||||
if self._retrieval_client is not None:
|
||||
await self._retrieval_client.close()
|
||||
self._retrieval_client = None
|
||||
|
||||
@override
|
||||
async def invoking(
|
||||
self,
|
||||
messages: ChatMessage | MutableSequence[ChatMessage],
|
||||
**kwargs: Any,
|
||||
) -> Context:
|
||||
"""Retrieve relevant context from Azure AI Search before model invocation.
|
||||
|
||||
Args:
|
||||
messages: User messages to use for context retrieval.
|
||||
**kwargs: Additional arguments (unused).
|
||||
|
||||
Returns:
|
||||
Context object with retrieved documents as messages.
|
||||
"""
|
||||
# Convert to list and filter to USER/ASSISTANT messages with text only
|
||||
messages_list = [messages] if isinstance(messages, ChatMessage) else list(messages)
|
||||
|
||||
filtered_messages = [
|
||||
msg
|
||||
for msg in messages_list
|
||||
if msg and msg.text and msg.text.strip() and msg.role in [Role.USER, Role.ASSISTANT]
|
||||
]
|
||||
|
||||
if not filtered_messages:
|
||||
return Context()
|
||||
|
||||
# Perform search based on mode
|
||||
if self.mode == "semantic":
|
||||
# Semantic mode: flatten messages to single query
|
||||
query = "\n".join(msg.text for msg in filtered_messages)
|
||||
search_result_parts = await self._semantic_search(query)
|
||||
else: # agentic
|
||||
# Agentic mode: pass recent messages as conversation history
|
||||
recent_messages = filtered_messages[-self.agentic_message_history_count :]
|
||||
search_result_parts = await self._agentic_search(recent_messages)
|
||||
|
||||
# Format results as context - return multiple messages for each result part
|
||||
if not search_result_parts:
|
||||
return Context()
|
||||
|
||||
# Create context messages: first message with prompt, then one message per result part
|
||||
context_messages = [ChatMessage(role=Role.USER, text=self.context_prompt)]
|
||||
context_messages.extend([ChatMessage(role=Role.USER, text=part) for part in search_result_parts])
|
||||
|
||||
return Context(messages=context_messages)
|
||||
|
||||
def _find_vector_fields(self, index: Any) -> list[str]:
|
||||
"""Find all fields that can store vectors (have dimensions defined).
|
||||
|
||||
Args:
|
||||
index: SearchIndex object from Azure Search.
|
||||
|
||||
Returns:
|
||||
List of vector field names.
|
||||
"""
|
||||
return [
|
||||
field.name
|
||||
for field in index.fields
|
||||
if field.vector_search_dimensions is not None and field.vector_search_dimensions > 0
|
||||
]
|
||||
|
||||
def _find_vectorizable_fields(self, index: Any, vector_fields: list[str]) -> list[str]:
|
||||
"""Find vector fields that have auto-vectorization configured.
|
||||
|
||||
These are fields that have a vectorizer in their profile, meaning the index
|
||||
can automatically vectorize text queries without needing a client-side embedding function.
|
||||
|
||||
Args:
|
||||
index: SearchIndex object from Azure Search.
|
||||
vector_fields: List of vector field names.
|
||||
|
||||
Returns:
|
||||
List of vectorizable field names (subset of vector_fields).
|
||||
"""
|
||||
vectorizable_fields: list[str] = []
|
||||
|
||||
# Check if index has vector search configuration
|
||||
if not index.vector_search or not index.vector_search.profiles:
|
||||
return vectorizable_fields
|
||||
|
||||
# For each vector field, check if it has a vectorizer configured
|
||||
for field in index.fields:
|
||||
if field.name in vector_fields and field.vector_search_profile_name:
|
||||
# Find the profile for this field
|
||||
profile = next(
|
||||
(p for p in index.vector_search.profiles if p.name == field.vector_search_profile_name), None
|
||||
)
|
||||
|
||||
if profile and hasattr(profile, "vectorizer_name") and profile.vectorizer_name:
|
||||
# This field has server-side vectorization configured
|
||||
vectorizable_fields.append(field.name)
|
||||
|
||||
return vectorizable_fields
|
||||
|
||||
async def _auto_discover_vector_field(self) -> None:
|
||||
"""Auto-discover vector field from index schema.
|
||||
|
||||
Attempts to find vector fields in the index and detect which have server-side
|
||||
vectorization configured. Prioritizes vectorizable fields (which can auto-embed text)
|
||||
over regular vector fields (which require client-side embedding).
|
||||
"""
|
||||
if self._auto_discovered_vector_field or self.vector_field_name:
|
||||
return # Already discovered or manually specified
|
||||
|
||||
try:
|
||||
# Use existing index client or create temporary one
|
||||
if not self._index_client:
|
||||
self._index_client = SearchIndexClient(endpoint=self.endpoint, credential=self.credential)
|
||||
index_client = self._index_client
|
||||
|
||||
# Get index schema
|
||||
index = await index_client.get_index(self.index_name)
|
||||
|
||||
# Step 1: Find all vector fields
|
||||
vector_fields = self._find_vector_fields(index)
|
||||
|
||||
if not vector_fields:
|
||||
# No vector fields found - keyword search only
|
||||
logger.info(f"No vector fields found in index '{self.index_name}'. Using keyword-only search.")
|
||||
self._auto_discovered_vector_field = True
|
||||
return
|
||||
|
||||
# Step 2: Find which vector fields have server-side vectorization
|
||||
vectorizable_fields = self._find_vectorizable_fields(index, vector_fields)
|
||||
|
||||
# Step 3: Decide which field to use
|
||||
if vectorizable_fields:
|
||||
# Prefer vectorizable fields (server-side embedding)
|
||||
if len(vectorizable_fields) == 1:
|
||||
self.vector_field_name = vectorizable_fields[0]
|
||||
self._auto_discovered_vector_field = True
|
||||
self._use_vectorizable_query = True # Use VectorizableTextQuery
|
||||
logger.info(
|
||||
f"Auto-discovered vectorizable field '{self.vector_field_name}' "
|
||||
f"with server-side vectorization. No embedding_function needed."
|
||||
)
|
||||
else:
|
||||
# Multiple vectorizable fields
|
||||
logger.warning(
|
||||
f"Multiple vectorizable fields found: {vectorizable_fields}. "
|
||||
f"Please specify vector_field_name explicitly. Using keyword-only search."
|
||||
)
|
||||
elif len(vector_fields) == 1:
|
||||
# Single vector field without vectorizer - needs client-side embedding
|
||||
self.vector_field_name = vector_fields[0]
|
||||
self._auto_discovered_vector_field = True
|
||||
self._use_vectorizable_query = False
|
||||
|
||||
if not self.embedding_function:
|
||||
logger.warning(
|
||||
f"Auto-discovered vector field '{self.vector_field_name}' without server-side vectorization. "
|
||||
f"Provide embedding_function for vector search, or it will fall back to keyword-only search."
|
||||
)
|
||||
self.vector_field_name = None
|
||||
else:
|
||||
# Multiple vector fields without vectorizers
|
||||
logger.warning(
|
||||
f"Multiple vector fields found: {vector_fields}. "
|
||||
f"Please specify vector_field_name explicitly. Using keyword-only search."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Log warning but continue with keyword search
|
||||
logger.warning(f"Failed to auto-discover vector field: {e}. Using keyword-only search.")
|
||||
|
||||
self._auto_discovered_vector_field = True # Mark as attempted
|
||||
|
||||
async def _semantic_search(self, query: str) -> list[str]:
|
||||
"""Perform semantic hybrid search with semantic ranking.
|
||||
|
||||
This is the recommended mode for most use cases. It combines:
|
||||
- Vector search (if embedding_function provided)
|
||||
- Keyword search (BM25)
|
||||
- Semantic reranking (if semantic_configuration_name provided)
|
||||
|
||||
Args:
|
||||
query: Search query text.
|
||||
|
||||
Returns:
|
||||
List of formatted search result strings, one per document.
|
||||
"""
|
||||
# Auto-discover vector field if not already done
|
||||
await self._auto_discover_vector_field()
|
||||
|
||||
vector_queries: list[VectorizableTextQuery | VectorizedQuery] = []
|
||||
|
||||
# Build vector query based on server-side vectorization or client-side embedding
|
||||
if self.vector_field_name:
|
||||
# Use larger k for vector query when semantic reranker is enabled for better ranking quality
|
||||
vector_k = max(self.top_k, 50) if self.semantic_configuration_name else self.top_k
|
||||
|
||||
if self._use_vectorizable_query:
|
||||
# Server-side vectorization: Index will auto-embed the text query
|
||||
vector_queries = [
|
||||
VectorizableTextQuery(
|
||||
text=query,
|
||||
k_nearest_neighbors=vector_k,
|
||||
fields=self.vector_field_name,
|
||||
)
|
||||
]
|
||||
elif self.embedding_function:
|
||||
# Client-side embedding: We provide the vector
|
||||
query_vector = await self.embedding_function(query)
|
||||
vector_queries = [
|
||||
VectorizedQuery(
|
||||
vector=query_vector,
|
||||
k_nearest_neighbors=vector_k,
|
||||
fields=self.vector_field_name,
|
||||
)
|
||||
]
|
||||
# else: vector_field_name is set but no vectorization available - skip vector search
|
||||
|
||||
# Build search parameters
|
||||
search_params: dict[str, Any] = {
|
||||
"search_text": query,
|
||||
"top": self.top_k,
|
||||
}
|
||||
|
||||
if vector_queries:
|
||||
search_params["vector_queries"] = vector_queries
|
||||
|
||||
# Add semantic ranking if configured
|
||||
if self.semantic_configuration_name:
|
||||
search_params["query_type"] = QueryType.SEMANTIC
|
||||
search_params["semantic_configuration_name"] = self.semantic_configuration_name
|
||||
search_params["query_caption"] = QueryCaptionType.EXTRACTIVE
|
||||
|
||||
# Execute search
|
||||
results = await self._search_client.search(**search_params) # type: ignore[reportUnknownVariableType]
|
||||
|
||||
# Format results with citations
|
||||
formatted_results: list[str] = []
|
||||
async for doc in results: # type: ignore[reportUnknownVariableType]
|
||||
# Extract document ID for citation
|
||||
doc_id = doc.get("id") or doc.get("@search.id") # type: ignore[reportUnknownVariableType]
|
||||
|
||||
# Use full document chunks with citation
|
||||
doc_text: str = self._extract_document_text(doc, doc_id=doc_id) # type: ignore[reportUnknownArgumentType]
|
||||
if doc_text:
|
||||
formatted_results.append(doc_text) # type: ignore[reportUnknownArgumentType]
|
||||
|
||||
return formatted_results
|
||||
|
||||
async def _ensure_knowledge_base(self) -> None:
|
||||
"""Ensure Knowledge Base and knowledge source are created.
|
||||
|
||||
This method is idempotent - it will only create resources if they don't exist.
|
||||
|
||||
Note: Azure SDK uses KnowledgeAgent classes internally, but the feature
|
||||
is marketed as "Knowledge Bases" in Azure AI Search.
|
||||
"""
|
||||
if self._knowledge_base_initialized or not self._index_client:
|
||||
return
|
||||
|
||||
# Runtime validation for agentic mode parameters
|
||||
if not self.knowledge_base_name:
|
||||
raise ValueError("knowledge_base_name is required for agentic mode")
|
||||
if not self.azure_openai_resource_url:
|
||||
raise ValueError("azure_openai_resource_url is required for agentic mode")
|
||||
if not self.azure_openai_deployment_name:
|
||||
raise ValueError("model_deployment_name is required for agentic mode")
|
||||
|
||||
knowledge_base_name = self.knowledge_base_name
|
||||
|
||||
# Step 1: Create or get knowledge source
|
||||
knowledge_source_name = f"{self.index_name}-source"
|
||||
|
||||
try:
|
||||
# Try to get existing knowledge source
|
||||
await self._index_client.get_knowledge_source(knowledge_source_name)
|
||||
except ResourceNotFoundError:
|
||||
# Create new knowledge source if it doesn't exist
|
||||
knowledge_source = SearchIndexKnowledgeSource(
|
||||
name=knowledge_source_name,
|
||||
description=f"Knowledge source for {self.index_name} search index",
|
||||
search_index_parameters=SearchIndexKnowledgeSourceParameters(
|
||||
search_index_name=self.index_name,
|
||||
),
|
||||
)
|
||||
await self._index_client.create_knowledge_source(knowledge_source)
|
||||
|
||||
# Step 2: Create or update Knowledge Base
|
||||
# Always create/update to ensure configuration is current
|
||||
aoai_params = AzureOpenAIVectorizerParameters(
|
||||
resource_url=self.azure_openai_resource_url,
|
||||
deployment_name=self.azure_openai_deployment_name,
|
||||
model_name=self.model_name,
|
||||
api_key=self.azure_openai_api_key,
|
||||
)
|
||||
|
||||
# Map output mode string to SDK enum
|
||||
output_mode = (
|
||||
KnowledgeRetrievalOutputMode.EXTRACTIVE_DATA
|
||||
if self.knowledge_base_output_mode == "extractive_data"
|
||||
else KnowledgeRetrievalOutputMode.ANSWER_SYNTHESIS
|
||||
)
|
||||
|
||||
# Map reasoning effort string to SDK class
|
||||
reasoning_effort_map: dict[str, KnowledgeRetrievalReasoningEffort] = {
|
||||
"minimal": KnowledgeRetrievalMinimalReasoningEffort(),
|
||||
"medium": KnowledgeRetrievalMediumReasoningEffort(),
|
||||
"low": KnowledgeRetrievalLowReasoningEffort(),
|
||||
}
|
||||
reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort]
|
||||
|
||||
knowledge_base = KnowledgeBase(
|
||||
name=knowledge_base_name,
|
||||
description=f"Knowledge Base for multi-hop retrieval across {self.index_name}",
|
||||
knowledge_sources=[
|
||||
KnowledgeSourceReference(
|
||||
name=knowledge_source_name,
|
||||
)
|
||||
],
|
||||
models=[KnowledgeBaseAzureOpenAIModel(azure_open_ai_parameters=aoai_params)],
|
||||
output_mode=output_mode,
|
||||
retrieval_reasoning_effort=reasoning_effort,
|
||||
)
|
||||
await self._index_client.create_or_update_knowledge_base(knowledge_base)
|
||||
|
||||
self._knowledge_base_initialized = True
|
||||
|
||||
# Create retrieval client now that Knowledge Base is initialized
|
||||
if _agentic_retrieval_available and self._retrieval_client is None:
|
||||
self._retrieval_client = KnowledgeBaseRetrievalClient(
|
||||
endpoint=self.endpoint,
|
||||
knowledge_base_name=knowledge_base_name,
|
||||
credential=self.credential,
|
||||
)
|
||||
|
||||
async def _agentic_search(self, messages: list[ChatMessage]) -> list[str]:
|
||||
"""Perform agentic retrieval with multi-hop reasoning using Knowledge Bases.
|
||||
|
||||
This mode uses query planning and is slightly slower than semantic search,
|
||||
but provides more accurate results through intelligent retrieval.
|
||||
|
||||
This method uses Azure AI Search Knowledge Bases which:
|
||||
1. Analyze the query and plan sub-queries
|
||||
2. Retrieve relevant documents across multiple sources
|
||||
3. Perform multi-hop reasoning with an LLM
|
||||
4. Synthesize a comprehensive answer with references
|
||||
|
||||
Args:
|
||||
messages: Conversation history to use for retrieval context.
|
||||
|
||||
Returns:
|
||||
List of answer parts from the Knowledge Base, one per content item.
|
||||
"""
|
||||
# Ensure Knowledge Base is initialized
|
||||
await self._ensure_knowledge_base()
|
||||
|
||||
# Map reasoning effort string to SDK class (for retrieval requests)
|
||||
reasoning_effort_map: dict[str, KBRetrievalReasoningEffort] = {
|
||||
"minimal": KBRetrievalMinimalReasoningEffort(),
|
||||
"medium": KBRetrievalMediumReasoningEffort(),
|
||||
"low": KBRetrievalLowReasoningEffort(),
|
||||
}
|
||||
reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort]
|
||||
|
||||
# Map output mode string to SDK enum (for retrieval requests)
|
||||
output_mode = (
|
||||
KBRetrievalOutputMode.EXTRACTIVE_DATA
|
||||
if self.knowledge_base_output_mode == "extractive_data"
|
||||
else KBRetrievalOutputMode.ANSWER_SYNTHESIS
|
||||
)
|
||||
|
||||
# For minimal reasoning, use intents API; for medium/low, use messages API
|
||||
if self.retrieval_reasoning_effort == "minimal":
|
||||
# Minimal reasoning uses intents with a single search query
|
||||
query = "\n".join(msg.text for msg in messages if msg.text)
|
||||
intents: list[KnowledgeRetrievalIntent] = [KnowledgeRetrievalSemanticIntent(search=query)]
|
||||
retrieval_request = KnowledgeBaseRetrievalRequest(
|
||||
intents=intents,
|
||||
retrieval_reasoning_effort=reasoning_effort,
|
||||
output_mode=output_mode,
|
||||
include_activity=True,
|
||||
)
|
||||
else:
|
||||
# Medium/low reasoning uses messages with conversation history
|
||||
kb_messages = [
|
||||
KnowledgeBaseMessage(
|
||||
role=msg.role.value if hasattr(msg.role, "value") else str(msg.role),
|
||||
content=[KnowledgeBaseMessageTextContent(text=msg.text)],
|
||||
)
|
||||
for msg in messages
|
||||
if msg.text
|
||||
]
|
||||
retrieval_request = KnowledgeBaseRetrievalRequest(
|
||||
messages=kb_messages,
|
||||
retrieval_reasoning_effort=reasoning_effort,
|
||||
output_mode=output_mode,
|
||||
include_activity=True,
|
||||
)
|
||||
|
||||
# Use reusable retrieval client
|
||||
if not self._retrieval_client:
|
||||
raise RuntimeError("Retrieval client not initialized. Ensure Knowledge Base is set up correctly.")
|
||||
|
||||
# Perform retrieval via Knowledge Base
|
||||
retrieval_result = await self._retrieval_client.retrieve(retrieval_request=retrieval_request)
|
||||
|
||||
# Extract answer parts from response
|
||||
if retrieval_result.response and len(retrieval_result.response) > 0:
|
||||
# Get the assistant's response (last message)
|
||||
assistant_message = retrieval_result.response[-1]
|
||||
if assistant_message.content:
|
||||
# Extract all text content items as separate parts
|
||||
answer_parts: list[str] = []
|
||||
for content_item in assistant_message.content:
|
||||
# Check if this is a text content item
|
||||
if isinstance(content_item, KnowledgeBaseMessageTextContent) and content_item.text:
|
||||
answer_parts.append(content_item.text)
|
||||
|
||||
if answer_parts:
|
||||
return answer_parts
|
||||
|
||||
# Fallback if no answer generated
|
||||
return ["No results found from Knowledge Base."]
|
||||
|
||||
def _extract_document_text(self, doc: dict[str, Any], doc_id: str | None = None) -> str:
|
||||
"""Extract readable text from a search document with optional citation.
|
||||
|
||||
Args:
|
||||
doc: Search result document.
|
||||
doc_id: Optional document ID for citation.
|
||||
|
||||
Returns:
|
||||
Formatted document text with citation if doc_id provided.
|
||||
"""
|
||||
# Try common text field names
|
||||
text = ""
|
||||
for field in ["content", "text", "description", "body", "chunk"]:
|
||||
if doc.get(field):
|
||||
text = str(doc[field])
|
||||
break
|
||||
|
||||
# Fallback: concatenate all string fields
|
||||
if not text:
|
||||
text_parts: list[str] = []
|
||||
for key, value in doc.items():
|
||||
if isinstance(value, str) and not key.startswith("@") and key != "id":
|
||||
text_parts.append(f"{key}: {value}")
|
||||
text = " | ".join(text_parts) if text_parts else ""
|
||||
|
||||
# Add citation if document ID provided
|
||||
if doc_id and text:
|
||||
return f"[Source: {doc_id}] {text}"
|
||||
return text
|
||||
@@ -0,0 +1,91 @@
|
||||
[project]
|
||||
name = "agent-framework-aisearch"
|
||||
description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251118"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core",
|
||||
"azure-search-documents==11.7.0b2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
]
|
||||
timeout = 120
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
exclude = ["examples"]
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_aisearch"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_aisearch"
|
||||
test = "pytest --cov=agent_framework_aisearch --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,992 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatMessage, Context, Role
|
||||
from agent_framework.azure import AzureAISearchContextProvider
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
from agent_framework_aisearch import AzureAISearchSettings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_search_client() -> AsyncMock:
|
||||
"""Create a mock SearchClient."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.search = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_index_client() -> AsyncMock:
|
||||
"""Create a mock SearchIndexClient."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_knowledge_source = AsyncMock()
|
||||
mock_client.create_knowledge_source = AsyncMock()
|
||||
mock_client.get_agent = AsyncMock()
|
||||
mock_client.create_agent = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_messages() -> list[ChatMessage]:
|
||||
"""Create sample chat messages for testing."""
|
||||
return [
|
||||
ChatMessage(role=Role.USER, text="What is in the documents?"),
|
||||
]
|
||||
|
||||
|
||||
class TestAzureAISearchSettings:
|
||||
"""Test AzureAISearchSettings configuration."""
|
||||
|
||||
def test_settings_with_direct_values(self) -> None:
|
||||
"""Test settings with direct values."""
|
||||
settings = AzureAISearchSettings(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
)
|
||||
assert settings.endpoint == "https://test.search.windows.net"
|
||||
assert settings.index_name == "test-index"
|
||||
# api_key is now SecretStr
|
||||
assert settings.api_key.get_secret_value() == "test-key"
|
||||
|
||||
def test_settings_with_env_file_path(self) -> None:
|
||||
"""Test settings with env_file_path parameter."""
|
||||
settings = AzureAISearchSettings(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
env_file_path="test.env",
|
||||
)
|
||||
assert settings.endpoint == "https://test.search.windows.net"
|
||||
assert settings.index_name == "test-index"
|
||||
|
||||
def test_provider_uses_settings_from_env(self) -> None:
|
||||
"""Test that provider creates settings internally from env."""
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
)
|
||||
assert provider.endpoint == "https://test.search.windows.net"
|
||||
assert provider.index_name == "test-index"
|
||||
|
||||
def test_provider_missing_endpoint_raises_error(self) -> None:
|
||||
"""Test that provider raises ServiceInitializationError without endpoint."""
|
||||
# Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading
|
||||
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")}
|
||||
with (
|
||||
patch.dict(os.environ, clean_env, clear=True),
|
||||
pytest.raises(ServiceInitializationError, match="endpoint is required"),
|
||||
):
|
||||
AzureAISearchContextProvider(
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
env_file_path="", # Disable .env file loading
|
||||
)
|
||||
|
||||
def test_provider_missing_index_name_raises_error(self) -> None:
|
||||
"""Test that provider raises ServiceInitializationError without index_name."""
|
||||
# Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading
|
||||
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")}
|
||||
with (
|
||||
patch.dict(os.environ, clean_env, clear=True),
|
||||
pytest.raises(ServiceInitializationError, match="index name is required"),
|
||||
):
|
||||
AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
api_key="test-key",
|
||||
env_file_path="", # Disable .env file loading
|
||||
)
|
||||
|
||||
def test_provider_missing_credential_raises_error(self) -> None:
|
||||
"""Test that provider raises ServiceInitializationError without credential."""
|
||||
# Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading
|
||||
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")}
|
||||
with (
|
||||
patch.dict(os.environ, clean_env, clear=True),
|
||||
pytest.raises(ServiceInitializationError, match="credential is required"),
|
||||
):
|
||||
AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
env_file_path="", # Disable .env file loading
|
||||
)
|
||||
|
||||
|
||||
class TestSearchProviderInitialization:
|
||||
"""Test initialization and configuration of AzureAISearchContextProvider."""
|
||||
|
||||
def test_init_semantic_mode_minimal(self) -> None:
|
||||
"""Test initialization with minimal semantic mode parameters."""
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
assert provider.endpoint == "https://test.search.windows.net"
|
||||
assert provider.index_name == "test-index"
|
||||
assert provider.mode == "semantic"
|
||||
assert provider.top_k == 5
|
||||
|
||||
def test_init_semantic_mode_with_vector_field_requires_embedding_function(self) -> None:
|
||||
"""Test that vector_field_name requires embedding_function."""
|
||||
with pytest.raises(ValueError, match="embedding_function is required"):
|
||||
AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
vector_field_name="embedding",
|
||||
)
|
||||
|
||||
def test_init_agentic_mode_requires_azure_openai_resource_url(self) -> None:
|
||||
"""Test that agentic mode requires azure_openai_resource_url."""
|
||||
with pytest.raises(ValueError, match="azure_openai_resource_url"):
|
||||
AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
)
|
||||
|
||||
def test_init_agentic_mode_requires_model_deployment_name(self) -> None:
|
||||
"""Test that agentic mode requires model_deployment_name."""
|
||||
with pytest.raises(ValueError, match="model_deployment_name"):
|
||||
AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
azure_ai_project_endpoint="https://test.services.ai.azure.com",
|
||||
azure_openai_resource_url="https://test.openai.azure.com",
|
||||
)
|
||||
|
||||
def test_init_agentic_mode_requires_knowledge_base_name(self) -> None:
|
||||
"""Test that agentic mode requires knowledge_base_name."""
|
||||
with pytest.raises(ValueError, match="knowledge_base_name"):
|
||||
AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
azure_ai_project_endpoint="https://test.services.ai.azure.com",
|
||||
model_deployment_name="gpt-4o",
|
||||
azure_openai_resource_url="https://test.openai.azure.com",
|
||||
)
|
||||
|
||||
def test_init_agentic_mode_with_all_params(self) -> None:
|
||||
"""Test initialization with all agentic mode parameters."""
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
azure_ai_project_endpoint="https://test.services.ai.azure.com",
|
||||
model_deployment_name="my-gpt-4o-deployment",
|
||||
model_name="gpt-4o",
|
||||
knowledge_base_name="test-kb",
|
||||
azure_openai_resource_url="https://test.openai.azure.com",
|
||||
)
|
||||
assert provider.mode == "agentic"
|
||||
assert provider.azure_ai_project_endpoint == "https://test.services.ai.azure.com"
|
||||
assert provider.azure_openai_resource_url == "https://test.openai.azure.com"
|
||||
assert provider.azure_openai_deployment_name == "my-gpt-4o-deployment"
|
||||
assert provider.model_name == "gpt-4o"
|
||||
assert provider.knowledge_base_name == "test-kb"
|
||||
|
||||
def test_init_model_name_defaults_to_deployment_name(self) -> None:
|
||||
"""Test that model_name defaults to deployment_name if not provided."""
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
azure_ai_project_endpoint="https://test.services.ai.azure.com",
|
||||
model_deployment_name="gpt-4o",
|
||||
knowledge_base_name="test-kb",
|
||||
azure_openai_resource_url="https://test.openai.azure.com",
|
||||
)
|
||||
assert provider.model_name == "gpt-4o"
|
||||
|
||||
def test_init_with_custom_context_prompt(self) -> None:
|
||||
"""Test initialization with custom context prompt."""
|
||||
custom_prompt = "Use the following information:"
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
context_prompt=custom_prompt,
|
||||
)
|
||||
assert provider.context_prompt == custom_prompt
|
||||
|
||||
def test_init_uses_default_context_prompt(self) -> None:
|
||||
"""Test that default context prompt is used when not provided."""
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
assert provider.context_prompt == provider._DEFAULT_SEARCH_CONTEXT_PROMPT
|
||||
|
||||
|
||||
class TestSemanticSearch:
|
||||
"""Test semantic search functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_semantic_search_basic(
|
||||
self, mock_search_class: MagicMock, sample_messages: list[ChatMessage]
|
||||
) -> None:
|
||||
"""Test basic semantic search without vector search."""
|
||||
# Setup mock
|
||||
mock_search_client = AsyncMock()
|
||||
mock_results = AsyncMock()
|
||||
mock_results.__aiter__.return_value = iter([{"content": "Test document content"}])
|
||||
mock_search_client.search.return_value = mock_results
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
context = await provider.invoking(sample_messages)
|
||||
|
||||
assert isinstance(context, Context)
|
||||
assert len(context.messages) > 1 # First message is prompt, rest are results
|
||||
# First message should be the context prompt
|
||||
assert "Use the following context" in context.messages[0].text
|
||||
# Second message should contain the search result
|
||||
assert "Test document content" in context.messages[1].text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_semantic_search_empty_query(self, mock_search_class: MagicMock) -> None:
|
||||
"""Test that empty queries return empty context."""
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
# Empty message
|
||||
context = await provider.invoking([ChatMessage(role=Role.USER, text="")])
|
||||
|
||||
assert isinstance(context, Context)
|
||||
assert len(context.messages) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_semantic_search_with_vector_query(
|
||||
self, mock_search_class: MagicMock, sample_messages: list[ChatMessage]
|
||||
) -> None:
|
||||
"""Test semantic search with vector query."""
|
||||
# Setup mock
|
||||
mock_search_client = AsyncMock()
|
||||
mock_results = AsyncMock()
|
||||
mock_results.__aiter__.return_value = iter([{"content": "Vector search result"}])
|
||||
mock_search_client.search.return_value = mock_results
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
# Mock embedding function
|
||||
async def mock_embed(text: str) -> list[float]:
|
||||
return [0.1, 0.2, 0.3]
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
vector_field_name="embedding",
|
||||
embedding_function=mock_embed,
|
||||
)
|
||||
|
||||
context = await provider.invoking(sample_messages)
|
||||
|
||||
assert isinstance(context, Context)
|
||||
assert len(context.messages) > 0
|
||||
# Verify that search was called
|
||||
mock_search_client.search.assert_called_once()
|
||||
|
||||
|
||||
class TestKnowledgeBaseSetup:
|
||||
"""Test Knowledge Base setup for agentic mode."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_ensure_knowledge_base_creates_when_not_exists(
|
||||
self, mock_search_class: MagicMock, mock_index_class: MagicMock
|
||||
) -> None:
|
||||
"""Test that Knowledge Base is created when it doesn't exist."""
|
||||
# Setup mocks
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
|
||||
mock_index_client.create_knowledge_source = AsyncMock()
|
||||
mock_index_client.get_knowledge_base.side_effect = ResourceNotFoundError("Not found")
|
||||
mock_index_client.create_or_update_knowledge_base = AsyncMock()
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
azure_ai_project_endpoint="https://test.services.ai.azure.com",
|
||||
model_deployment_name="gpt-4o",
|
||||
model_name="gpt-4o",
|
||||
knowledge_base_name="test-kb",
|
||||
azure_openai_resource_url="https://test.openai.azure.com",
|
||||
)
|
||||
|
||||
await provider._ensure_knowledge_base()
|
||||
|
||||
# Verify knowledge source was created
|
||||
mock_index_client.create_knowledge_source.assert_called_once()
|
||||
# Verify Knowledge Base was created
|
||||
mock_index_client.create_or_update_knowledge_base.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_ensure_knowledge_base_skips_when_exists(
|
||||
self, mock_search_class: MagicMock, mock_index_class: MagicMock
|
||||
) -> None:
|
||||
"""Test that Knowledge Base setup is skipped when already exists."""
|
||||
# Setup mocks
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index_client.get_knowledge_source.return_value = MagicMock() # Exists
|
||||
mock_index_client.get_knowledge_base.return_value = MagicMock() # Exists
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
azure_ai_project_endpoint="https://test.services.ai.azure.com",
|
||||
model_deployment_name="gpt-4o",
|
||||
knowledge_base_name="test-kb",
|
||||
azure_openai_resource_url="https://test.openai.azure.com",
|
||||
)
|
||||
|
||||
await provider._ensure_knowledge_base()
|
||||
|
||||
# Verify nothing was created
|
||||
mock_index_client.create_knowledge_source.assert_not_called()
|
||||
mock_index_client.create_agent.assert_not_called()
|
||||
|
||||
|
||||
class TestContextProviderLifecycle:
|
||||
"""Test context provider lifecycle methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_context_manager(self, mock_search_class: MagicMock) -> None:
|
||||
"""Test that provider can be used as async context manager."""
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
async with AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
) as provider:
|
||||
assert provider is not None
|
||||
assert isinstance(provider, AzureAISearchContextProvider)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_context_manager_agentic_cleanup(
|
||||
self, mock_search_class: MagicMock, mock_index_class: MagicMock, mock_retrieval_class: MagicMock
|
||||
) -> None:
|
||||
"""Test that agentic mode provider cleans up retrieval client."""
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
mock_retrieval_client = AsyncMock()
|
||||
mock_retrieval_client.close = AsyncMock()
|
||||
mock_retrieval_class.return_value = mock_retrieval_client
|
||||
|
||||
async with AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
azure_ai_project_endpoint="https://test.services.ai.azure.com",
|
||||
model_deployment_name="gpt-4o",
|
||||
knowledge_base_name="test-kb",
|
||||
azure_openai_resource_url="https://test.openai.azure.com",
|
||||
) as provider:
|
||||
# Simulate retrieval client being created
|
||||
provider._retrieval_client = mock_retrieval_client
|
||||
|
||||
# Verify cleanup was called
|
||||
mock_retrieval_client.close.assert_called_once()
|
||||
|
||||
def test_string_api_key_conversion(self) -> None:
|
||||
"""Test that string api_key is converted to AzureKeyCredential."""
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="my-api-key", # String api_key
|
||||
mode="semantic",
|
||||
)
|
||||
assert isinstance(provider.credential, AzureKeyCredential)
|
||||
|
||||
|
||||
class TestMessageFiltering:
|
||||
"""Test message filtering functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_filters_non_user_assistant_messages(self, mock_search_class: MagicMock) -> None:
|
||||
"""Test that only USER and ASSISTANT messages are processed."""
|
||||
# Setup mock
|
||||
mock_search_client = AsyncMock()
|
||||
mock_results = AsyncMock()
|
||||
mock_results.__aiter__.return_value = iter([{"content": "Test result"}])
|
||||
mock_search_client.search.return_value = mock_results
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
# Mix of message types
|
||||
messages = [
|
||||
ChatMessage(role=Role.SYSTEM, text="System message"),
|
||||
ChatMessage(role=Role.USER, text="User message"),
|
||||
ChatMessage(role=Role.ASSISTANT, text="Assistant message"),
|
||||
ChatMessage(role=Role.TOOL, text="Tool message"),
|
||||
]
|
||||
|
||||
context = await provider.invoking(messages)
|
||||
|
||||
# Should have processed only USER and ASSISTANT messages
|
||||
assert isinstance(context, Context)
|
||||
mock_search_client.search.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_filters_empty_messages(self, mock_search_class: MagicMock) -> None:
|
||||
"""Test that empty/whitespace messages are filtered out."""
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
# Messages with empty/whitespace text
|
||||
messages = [
|
||||
ChatMessage(role=Role.USER, text=""),
|
||||
ChatMessage(role=Role.USER, text=" "),
|
||||
ChatMessage(role=Role.USER, text=None),
|
||||
]
|
||||
|
||||
context = await provider.invoking(messages)
|
||||
|
||||
# Should return empty context
|
||||
assert len(context.messages) == 0
|
||||
|
||||
|
||||
class TestCitations:
|
||||
"""Test citation functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_citations_included_in_semantic_search(self, mock_search_class: MagicMock) -> None:
|
||||
"""Test that citations are included in semantic search results."""
|
||||
# Setup mock with document ID
|
||||
mock_search_client = AsyncMock()
|
||||
mock_results = AsyncMock()
|
||||
mock_doc = {"id": "doc123", "content": "Test document content"}
|
||||
mock_results.__aiter__.return_value = iter([mock_doc])
|
||||
mock_search_client.search.return_value = mock_results
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
context = await provider.invoking([ChatMessage(role=Role.USER, text="test query")])
|
||||
|
||||
# Check that citation is included
|
||||
assert isinstance(context, Context)
|
||||
assert len(context.messages) > 1 # First message is prompt, rest are results
|
||||
# Citation should be in the result message (second message)
|
||||
assert "[Source: doc123]" in context.messages[1].text
|
||||
assert "Test document content" in context.messages[1].text
|
||||
|
||||
|
||||
class TestAgenticSearch:
|
||||
"""Test agentic search functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_agentic_search_basic(
|
||||
self,
|
||||
mock_search_class: MagicMock,
|
||||
mock_index_class: MagicMock,
|
||||
mock_retrieval_class: MagicMock,
|
||||
sample_messages: list[ChatMessage],
|
||||
) -> None:
|
||||
"""Test basic agentic search with Knowledge Base retrieval."""
|
||||
# Setup search client mock
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
# Setup index client mock
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
|
||||
mock_index_client.create_knowledge_source = AsyncMock()
|
||||
mock_index_client.create_or_update_knowledge_base = AsyncMock()
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
# Setup retrieval client mock with response
|
||||
mock_retrieval_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_message = MagicMock()
|
||||
mock_content = MagicMock()
|
||||
mock_content.text = "Agentic search result"
|
||||
# Make it pass isinstance check
|
||||
from agent_framework_aisearch._search_provider import _agentic_retrieval_available
|
||||
|
||||
if _agentic_retrieval_available:
|
||||
from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent
|
||||
|
||||
mock_content.__class__ = KnowledgeBaseMessageTextContent
|
||||
mock_message.content = [mock_content]
|
||||
mock_response.response = [mock_message]
|
||||
mock_retrieval_client.retrieve.return_value = mock_response
|
||||
mock_retrieval_client.close = AsyncMock()
|
||||
mock_retrieval_class.return_value = mock_retrieval_client
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
azure_ai_project_endpoint="https://test.services.ai.azure.com",
|
||||
model_deployment_name="gpt-4o",
|
||||
knowledge_base_name="test-kb",
|
||||
azure_openai_resource_url="https://test.openai.azure.com",
|
||||
)
|
||||
|
||||
context = await provider.invoking(sample_messages)
|
||||
|
||||
assert isinstance(context, Context)
|
||||
# Should have at least the prompt message
|
||||
assert len(context.messages) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_agentic_search_no_results(
|
||||
self,
|
||||
mock_search_class: MagicMock,
|
||||
mock_index_class: MagicMock,
|
||||
mock_retrieval_class: MagicMock,
|
||||
sample_messages: list[ChatMessage],
|
||||
) -> None:
|
||||
"""Test agentic search when no results are returned."""
|
||||
# Setup mocks
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
|
||||
mock_index_client.create_knowledge_source = AsyncMock()
|
||||
mock_index_client.create_or_update_knowledge_base = AsyncMock()
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
# Empty response
|
||||
mock_retrieval_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.response = []
|
||||
mock_retrieval_client.retrieve.return_value = mock_response
|
||||
mock_retrieval_client.close = AsyncMock()
|
||||
mock_retrieval_class.return_value = mock_retrieval_client
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
azure_ai_project_endpoint="https://test.services.ai.azure.com",
|
||||
model_deployment_name="gpt-4o",
|
||||
knowledge_base_name="test-kb",
|
||||
azure_openai_resource_url="https://test.openai.azure.com",
|
||||
)
|
||||
|
||||
context = await provider.invoking(sample_messages)
|
||||
|
||||
assert isinstance(context, Context)
|
||||
# Should have fallback message
|
||||
assert len(context.messages) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_agentic_search_with_medium_reasoning(
|
||||
self,
|
||||
mock_search_class: MagicMock,
|
||||
mock_index_class: MagicMock,
|
||||
mock_retrieval_class: MagicMock,
|
||||
sample_messages: list[ChatMessage],
|
||||
) -> None:
|
||||
"""Test agentic search with medium reasoning effort."""
|
||||
# Setup mocks
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
|
||||
mock_index_client.create_knowledge_source = AsyncMock()
|
||||
mock_index_client.create_or_update_knowledge_base = AsyncMock()
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
mock_retrieval_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_message = MagicMock()
|
||||
mock_content = MagicMock()
|
||||
mock_content.text = "Medium reasoning result"
|
||||
from agent_framework_aisearch._search_provider import _agentic_retrieval_available
|
||||
|
||||
if _agentic_retrieval_available:
|
||||
from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent
|
||||
|
||||
mock_content.__class__ = KnowledgeBaseMessageTextContent
|
||||
mock_message.content = [mock_content]
|
||||
mock_response.response = [mock_message]
|
||||
mock_retrieval_client.retrieve.return_value = mock_response
|
||||
mock_retrieval_client.close = AsyncMock()
|
||||
mock_retrieval_class.return_value = mock_retrieval_client
|
||||
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="agentic",
|
||||
azure_ai_project_endpoint="https://test.services.ai.azure.com",
|
||||
model_deployment_name="gpt-4o",
|
||||
knowledge_base_name="test-kb",
|
||||
azure_openai_resource_url="https://test.openai.azure.com",
|
||||
retrieval_reasoning_effort="medium", # Test medium reasoning
|
||||
)
|
||||
|
||||
context = await provider.invoking(sample_messages)
|
||||
|
||||
assert isinstance(context, Context)
|
||||
assert len(context.messages) >= 1
|
||||
|
||||
|
||||
class TestVectorFieldAutoDiscovery:
|
||||
"""Test vector field auto-discovery functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_auto_discovers_single_vector_field(
|
||||
self, mock_search_class: MagicMock, mock_index_class: MagicMock
|
||||
) -> None:
|
||||
"""Test that single vector field is auto-discovered."""
|
||||
# Setup search client mock
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
# Setup index client mock
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index = MagicMock()
|
||||
|
||||
# Create mock field with vector_search_dimensions attribute
|
||||
mock_vector_field = MagicMock()
|
||||
mock_vector_field.name = "embedding_vector"
|
||||
mock_vector_field.vector_search_dimensions = 1536
|
||||
|
||||
mock_index.fields = [mock_vector_field]
|
||||
mock_index_client.get_index.return_value = mock_index
|
||||
mock_index_client.close = AsyncMock()
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
# Create provider without specifying vector_field_name
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
# Trigger auto-discovery
|
||||
await provider._auto_discover_vector_field()
|
||||
|
||||
# Vector field should be auto-discovered but not used without embedding function
|
||||
assert provider._auto_discovered_vector_field is True
|
||||
# Should be cleared since no embedding function
|
||||
assert provider.vector_field_name is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_detection_accuracy(self) -> None:
|
||||
"""Test that vector field detection logic correctly identifies vector fields."""
|
||||
from azure.search.documents.indexes.models import SearchField
|
||||
|
||||
# Create real SearchField objects to test the detection logic
|
||||
vector_field = SearchField(
|
||||
name="embedding_vector", type="Collection(Edm.Single)", vector_search_dimensions=1536, searchable=True
|
||||
)
|
||||
|
||||
string_field = SearchField(name="content", type="Edm.String", searchable=True)
|
||||
|
||||
number_field = SearchField(name="price", type="Edm.Double", filterable=True)
|
||||
|
||||
# Test detection logic directly
|
||||
is_vector_1 = vector_field.vector_search_dimensions is not None and vector_field.vector_search_dimensions > 0
|
||||
is_vector_2 = string_field.vector_search_dimensions is not None and string_field.vector_search_dimensions > 0
|
||||
is_vector_3 = number_field.vector_search_dimensions is not None and number_field.vector_search_dimensions > 0
|
||||
|
||||
# Only the vector field should be detected
|
||||
assert is_vector_1 is True
|
||||
assert is_vector_2 is False
|
||||
assert is_vector_3 is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_no_false_positives_on_string_fields(
|
||||
self, mock_search_class: MagicMock, mock_index_class: MagicMock
|
||||
) -> None:
|
||||
"""Test that regular string fields are not detected as vector fields."""
|
||||
# Setup search client mock
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
# Setup index with only string fields (no vectors)
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index = MagicMock()
|
||||
|
||||
# All fields have vector_search_dimensions = None
|
||||
mock_fields = []
|
||||
for name in ["id", "title", "content", "category"]:
|
||||
field = MagicMock()
|
||||
field.name = name
|
||||
field.vector_search_dimensions = None
|
||||
field.vector_search_profile_name = None
|
||||
mock_fields.append(field)
|
||||
|
||||
mock_index.fields = mock_fields
|
||||
mock_index_client.get_index.return_value = mock_index
|
||||
mock_index_client.close = AsyncMock()
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
# Create provider
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
# Trigger auto-discovery
|
||||
await provider._auto_discover_vector_field()
|
||||
|
||||
# Should NOT detect any vector fields
|
||||
assert provider.vector_field_name is None
|
||||
assert provider._auto_discovered_vector_field is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_multiple_vector_fields_without_vectorizer(
|
||||
self, mock_search_class: MagicMock, mock_index_class: MagicMock
|
||||
) -> None:
|
||||
"""Test that multiple vector fields without vectorizer logs warning and uses keyword search."""
|
||||
# Setup search client mock
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
# Setup index with multiple vector fields (no vectorizers)
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index = MagicMock()
|
||||
|
||||
# Multiple vector fields
|
||||
mock_fields = []
|
||||
for name in ["embedding1", "embedding2"]:
|
||||
field = MagicMock()
|
||||
field.name = name
|
||||
field.vector_search_dimensions = 1536
|
||||
field.vector_search_profile_name = None # No vectorizer
|
||||
mock_fields.append(field)
|
||||
|
||||
mock_index.fields = mock_fields
|
||||
mock_index.vector_search = None # No vector search config
|
||||
mock_index_client.get_index.return_value = mock_index
|
||||
mock_index_client.close = AsyncMock()
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
# Create provider
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
# Trigger auto-discovery
|
||||
await provider._auto_discover_vector_field()
|
||||
|
||||
# Should NOT use any vector field (multiple fields, can't choose)
|
||||
assert provider.vector_field_name is None
|
||||
assert provider._auto_discovered_vector_field is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_multiple_vectorizable_fields(
|
||||
self, mock_search_class: MagicMock, mock_index_class: MagicMock
|
||||
) -> None:
|
||||
"""Test that multiple vectorizable fields logs warning and uses keyword search."""
|
||||
# Setup search client mock
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
# Setup index with multiple vectorizable fields
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index = MagicMock()
|
||||
|
||||
# Multiple vector fields with vectorizers
|
||||
mock_fields = []
|
||||
for name in ["embedding1", "embedding2"]:
|
||||
field = MagicMock()
|
||||
field.name = name
|
||||
field.vector_search_dimensions = 1536
|
||||
field.vector_search_profile_name = f"{name}-profile"
|
||||
mock_fields.append(field)
|
||||
|
||||
mock_index.fields = mock_fields
|
||||
|
||||
# Setup vector search config with profiles that have vectorizers
|
||||
mock_profile1 = MagicMock()
|
||||
mock_profile1.name = "embedding1-profile"
|
||||
mock_profile1.vectorizer_name = "vectorizer1"
|
||||
|
||||
mock_profile2 = MagicMock()
|
||||
mock_profile2.name = "embedding2-profile"
|
||||
mock_profile2.vectorizer_name = "vectorizer2"
|
||||
|
||||
mock_index.vector_search = MagicMock()
|
||||
mock_index.vector_search.profiles = [mock_profile1, mock_profile2]
|
||||
|
||||
mock_index_client.get_index.return_value = mock_index
|
||||
mock_index_client.close = AsyncMock()
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
# Create provider
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
# Trigger auto-discovery
|
||||
await provider._auto_discover_vector_field()
|
||||
|
||||
# Should NOT use any vector field (multiple vectorizable fields, can't choose)
|
||||
assert provider.vector_field_name is None
|
||||
assert provider._auto_discovered_vector_field is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
|
||||
@patch("agent_framework_aisearch._search_provider.SearchClient")
|
||||
async def test_single_vectorizable_field_detected(
|
||||
self, mock_search_class: MagicMock, mock_index_class: MagicMock
|
||||
) -> None:
|
||||
"""Test that single vectorizable field is auto-detected for server-side vectorization."""
|
||||
# Setup search client mock
|
||||
mock_search_client = AsyncMock()
|
||||
mock_search_class.return_value = mock_search_client
|
||||
|
||||
# Setup index with single vectorizable field
|
||||
mock_index_client = AsyncMock()
|
||||
mock_index = MagicMock()
|
||||
|
||||
# Single vector field with vectorizer
|
||||
mock_field = MagicMock()
|
||||
mock_field.name = "embedding"
|
||||
mock_field.vector_search_dimensions = 1536
|
||||
mock_field.vector_search_profile_name = "embedding-profile"
|
||||
|
||||
mock_index.fields = [mock_field]
|
||||
|
||||
# Setup vector search config with profile that has vectorizer
|
||||
mock_profile = MagicMock()
|
||||
mock_profile.name = "embedding-profile"
|
||||
mock_profile.vectorizer_name = "openai-vectorizer"
|
||||
|
||||
mock_index.vector_search = MagicMock()
|
||||
mock_index.vector_search.profiles = [mock_profile]
|
||||
|
||||
mock_index_client.get_index.return_value = mock_index
|
||||
mock_index_client.close = AsyncMock()
|
||||
mock_index_class.return_value = mock_index_client
|
||||
|
||||
# Create provider
|
||||
provider = AzureAISearchContextProvider(
|
||||
endpoint="https://test.search.windows.net",
|
||||
index_name="test-index",
|
||||
api_key="test-key",
|
||||
mode="semantic",
|
||||
)
|
||||
|
||||
# Trigger auto-discovery
|
||||
await provider._auto_discover_vector_field()
|
||||
|
||||
# Should detect the vectorizable field
|
||||
assert provider.vector_field_name == "embedding"
|
||||
assert provider._auto_discovered_vector_field is True
|
||||
assert provider._use_vectorizable_query is True # Server-side vectorization
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251114"
|
||||
version = "1.0.0b251120"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -310,8 +310,8 @@ class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
return run_options
|
||||
|
||||
async def initialize_client(self) -> None:
|
||||
"""Initialize OpenAI client asynchronously."""
|
||||
self.client = await self.project_client.get_openai_client() # type: ignore
|
||||
"""Initialize OpenAI client."""
|
||||
self.client = self.project_client.get_openai_client() # type: ignore
|
||||
|
||||
def _update_agent_name(self, agent_name: str | None) -> None:
|
||||
"""Update the agent name in the chat client.
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251114"
|
||||
version = "1.0.0b251120"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core",
|
||||
"azure-ai-projects >= 2.0.0b1",
|
||||
"azure-ai-projects >= 2.0.0b2",
|
||||
"azure-ai-agents == 1.2.0b5",
|
||||
"aiohttp",
|
||||
]
|
||||
|
||||
@@ -268,7 +268,7 @@ async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock)
|
||||
client = create_test_azure_ai_client(mock_project_client)
|
||||
|
||||
mock_openai_client = MagicMock()
|
||||
mock_project_client.get_openai_client = AsyncMock(return_value=mock_openai_client)
|
||||
mock_project_client.get_openai_client = MagicMock(return_value=mock_openai_client)
|
||||
|
||||
await client.initialize_client()
|
||||
|
||||
|
||||
@@ -16,27 +16,29 @@ import azure.functions as func
|
||||
from agent_framework import AgentProtocol, get_logger
|
||||
|
||||
from ._callbacks import AgentResponseCallbackProtocol
|
||||
from ._constants import (
|
||||
DEFAULT_MAX_POLL_RETRIES,
|
||||
DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
MIMETYPE_APPLICATION_JSON,
|
||||
MIMETYPE_TEXT_PLAIN,
|
||||
REQUEST_RESPONSE_FORMAT_JSON,
|
||||
REQUEST_RESPONSE_FORMAT_TEXT,
|
||||
THREAD_ID_FIELD,
|
||||
THREAD_ID_HEADER,
|
||||
WAIT_FOR_RESPONSE_FIELD,
|
||||
WAIT_FOR_RESPONSE_HEADER,
|
||||
)
|
||||
from ._durable_agent_state import DurableAgentState
|
||||
from ._entities import create_agent_entity
|
||||
from ._errors import IncomingRequestError
|
||||
from ._models import AgentSessionId, RunRequest
|
||||
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
|
||||
from ._state import AgentState
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions")
|
||||
|
||||
THREAD_ID_FIELD: str = "thread_id"
|
||||
RESPONSE_FORMAT_JSON: str = "json"
|
||||
RESPONSE_FORMAT_TEXT: str = "text"
|
||||
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
|
||||
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
|
||||
|
||||
|
||||
EntityHandler = Callable[[df.DurableEntityContext], None]
|
||||
HandlerT = TypeVar("HandlerT", bound=Callable[..., Any])
|
||||
|
||||
DEFAULT_MAX_POLL_RETRIES: int = 30
|
||||
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class DFAppBase:
|
||||
@@ -317,11 +319,11 @@ class AgentFunctionApp(DFAppBase):
|
||||
"""
|
||||
logger.debug(f"[HTTP Trigger] Received request on route: /api/agents/{agent_name}/run")
|
||||
|
||||
response_format: str = RESPONSE_FORMAT_JSON
|
||||
request_response_format: str = REQUEST_RESPONSE_FORMAT_JSON
|
||||
thread_id: str | None = None
|
||||
|
||||
try:
|
||||
req_body, message, response_format = self._parse_incoming_request(req)
|
||||
req_body, message, request_response_format = self._parse_incoming_request(req)
|
||||
thread_id = self._resolve_thread_id(req=req, req_body=req_body)
|
||||
wait_for_response = self._should_wait_for_response(req=req, req_body=req_body)
|
||||
|
||||
@@ -334,7 +336,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
return self._create_http_response(
|
||||
payload={"error": "Message is required"},
|
||||
status_code=400,
|
||||
response_format=response_format,
|
||||
request_response_format=request_response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
@@ -351,6 +353,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
message,
|
||||
thread_id,
|
||||
correlation_id,
|
||||
request_response_format,
|
||||
)
|
||||
logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request)
|
||||
await client.signal_entity(entity_instance_id, "run_agent", run_request)
|
||||
@@ -370,7 +373,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
return self._create_http_response(
|
||||
payload=result,
|
||||
status_code=200 if result.get("status") == "success" else 500,
|
||||
response_format=response_format,
|
||||
request_response_format=request_response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
@@ -383,7 +386,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
return self._create_http_response(
|
||||
payload=accepted_response,
|
||||
status_code=202,
|
||||
response_format=response_format,
|
||||
request_response_format=request_response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
@@ -392,7 +395,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
return self._create_http_response(
|
||||
payload={"error": str(exc)},
|
||||
status_code=exc.status_code,
|
||||
response_format=response_format,
|
||||
request_response_format=request_response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
@@ -400,7 +403,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
return self._create_http_response(
|
||||
payload={"error": "Invalid JSON"},
|
||||
status_code=400,
|
||||
response_format=response_format,
|
||||
request_response_format=request_response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -408,7 +411,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
return self._create_http_response(
|
||||
payload={"error": str(exc)},
|
||||
status_code=500,
|
||||
response_format=response_format,
|
||||
request_response_format=request_response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
@@ -466,7 +469,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
return func.HttpResponse(
|
||||
json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self.agents)}),
|
||||
status_code=200,
|
||||
mimetype="application/json",
|
||||
mimetype=MIMETYPE_APPLICATION_JSON,
|
||||
)
|
||||
|
||||
_ = health_check
|
||||
@@ -491,7 +494,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
self,
|
||||
client: df.DurableOrchestrationClient,
|
||||
entity_instance_id: df.EntityId,
|
||||
) -> AgentState | None:
|
||||
) -> DurableAgentState | None:
|
||||
state_response = await client.read_entity_state(entity_instance_id)
|
||||
if not state_response or not state_response.entity_exists:
|
||||
return None
|
||||
@@ -502,9 +505,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
|
||||
typed_state_payload = cast(dict[str, Any], state_payload)
|
||||
|
||||
agent_state = AgentState()
|
||||
agent_state.restore_state(typed_state_payload)
|
||||
return agent_state
|
||||
return DurableAgentState.from_dict(typed_state_payload)
|
||||
|
||||
async def _get_response_from_entity(
|
||||
self,
|
||||
@@ -580,31 +581,58 @@ class AgentFunctionApp(DFAppBase):
|
||||
|
||||
return result
|
||||
|
||||
def _build_response_payload(
|
||||
self,
|
||||
*,
|
||||
response: str | None,
|
||||
message: str,
|
||||
thread_id: str,
|
||||
status: str,
|
||||
correlation_id: str,
|
||||
extra_fields: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a consistent response structure and allow optional extra fields."""
|
||||
payload = {
|
||||
"response": response,
|
||||
"message": message,
|
||||
THREAD_ID_FIELD: thread_id,
|
||||
"status": status,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
if extra_fields:
|
||||
payload.update(extra_fields)
|
||||
return payload
|
||||
|
||||
async def _build_timeout_result(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
|
||||
"""Create the timeout response."""
|
||||
return {
|
||||
"response": "Agent is still processing or timed out...",
|
||||
"message": message,
|
||||
THREAD_ID_FIELD: thread_id,
|
||||
"status": "timeout",
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
return self._build_response_payload(
|
||||
response="Agent is still processing or timed out...",
|
||||
message=message,
|
||||
thread_id=thread_id,
|
||||
status="timeout",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def _build_success_result(
|
||||
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: AgentState
|
||||
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: DurableAgentState
|
||||
) -> dict[str, Any]:
|
||||
"""Build the success result returned to the HTTP caller."""
|
||||
return {
|
||||
"response": response_data.get("content"),
|
||||
"message": message,
|
||||
THREAD_ID_FIELD: thread_id,
|
||||
"status": "success",
|
||||
"message_count": response_data.get("message_count", state.message_count),
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
return self._build_response_payload(
|
||||
response=response_data.get("content"),
|
||||
message=message,
|
||||
thread_id=thread_id,
|
||||
status="success",
|
||||
correlation_id=correlation_id,
|
||||
extra_fields={"message_count": response_data.get("message_count", state.message_count)},
|
||||
)
|
||||
|
||||
def _build_request_data(
|
||||
self, req_body: dict[str, Any], message: str, thread_id: str, correlation_id: str
|
||||
self,
|
||||
req_body: dict[str, Any],
|
||||
message: str,
|
||||
thread_id: str,
|
||||
correlation_id: str,
|
||||
request_response_format: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Create the durable entity request payload."""
|
||||
enable_tool_calls_value = req_body.get("enable_tool_calls")
|
||||
@@ -613,6 +641,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
return RunRequest(
|
||||
message=message,
|
||||
role=req_body.get("role"),
|
||||
request_response_format=request_response_format,
|
||||
response_format=req_body.get("response_format"),
|
||||
enable_tool_calls=enable_tool_calls,
|
||||
thread_id=thread_id,
|
||||
@@ -621,23 +650,23 @@ class AgentFunctionApp(DFAppBase):
|
||||
|
||||
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
|
||||
"""Build the response returned when not waiting for completion."""
|
||||
return {
|
||||
"response": "Agent request accepted",
|
||||
"message": message,
|
||||
THREAD_ID_FIELD: thread_id,
|
||||
"status": "accepted",
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
return self._build_response_payload(
|
||||
response="Agent request accepted",
|
||||
message=message,
|
||||
thread_id=thread_id,
|
||||
status="accepted",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def _create_http_response(
|
||||
self,
|
||||
payload: dict[str, Any] | str,
|
||||
status_code: int,
|
||||
response_format: str,
|
||||
request_response_format: str,
|
||||
thread_id: str | None,
|
||||
) -> func.HttpResponse:
|
||||
"""Create the HTTP response using helper serializers for clarity."""
|
||||
if response_format == RESPONSE_FORMAT_TEXT:
|
||||
if request_response_format == REQUEST_RESPONSE_FORMAT_TEXT:
|
||||
return self._build_plain_text_response(payload=payload, status_code=status_code, thread_id=thread_id)
|
||||
|
||||
return self._build_json_response(payload=payload, status_code=status_code)
|
||||
@@ -650,13 +679,13 @@ class AgentFunctionApp(DFAppBase):
|
||||
) -> func.HttpResponse:
|
||||
"""Return a plain-text response with optional thread identifier header."""
|
||||
body_text = payload if isinstance(payload, str) else self._convert_payload_to_text(payload)
|
||||
headers = {"x-ms-thread-id": thread_id} if thread_id is not None else None
|
||||
return func.HttpResponse(body_text, status_code=status_code, mimetype="text/plain", headers=headers)
|
||||
headers = {THREAD_ID_HEADER: thread_id} if thread_id is not None else None
|
||||
return func.HttpResponse(body_text, status_code=status_code, mimetype=MIMETYPE_TEXT_PLAIN, headers=headers)
|
||||
|
||||
def _build_json_response(self, payload: dict[str, Any] | str, status_code: int) -> func.HttpResponse:
|
||||
"""Return the JSON response, serializing dictionaries as needed."""
|
||||
body_json = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
return func.HttpResponse(body_json, status_code=status_code, mimetype="application/json")
|
||||
return func.HttpResponse(body_json, status_code=status_code, mimetype=MIMETYPE_APPLICATION_JSON)
|
||||
|
||||
def _convert_payload_to_text(self, payload: dict[str, Any]) -> str:
|
||||
"""Convert a structured payload into a human-readable text response."""
|
||||
@@ -702,18 +731,19 @@ class AgentFunctionApp(DFAppBase):
|
||||
normalized_content_type = self._extract_content_type(headers)
|
||||
body_parser, body_format = self._select_body_parser(normalized_content_type)
|
||||
prefers_json = self._accepts_json_response(headers)
|
||||
response_format = self._select_response_format(body_format=body_format, prefers_json=prefers_json)
|
||||
request_response_format = self._select_request_response_format(
|
||||
body_format=body_format, prefers_json=prefers_json
|
||||
)
|
||||
|
||||
req_body, message = body_parser(req)
|
||||
return req_body, message, response_format
|
||||
return req_body, message, request_response_format
|
||||
|
||||
def _extract_normalized_headers(self, req: func.HttpRequest) -> dict[str, str]:
|
||||
"""Create a lowercase header mapping from the incoming request."""
|
||||
headers: dict[str, str] = {}
|
||||
raw_headers = req.headers
|
||||
if isinstance(raw_headers, Mapping):
|
||||
header_mapping: Mapping[str, Any] = cast(Mapping[str, Any], raw_headers)
|
||||
for key, value in header_mapping.items():
|
||||
for key, value in raw_headers.items():
|
||||
if value is not None:
|
||||
headers[str(key).lower()] = str(value)
|
||||
return headers
|
||||
@@ -729,9 +759,9 @@ class AgentFunctionApp(DFAppBase):
|
||||
normalized_content_type: str,
|
||||
) -> tuple[Callable[[func.HttpRequest], tuple[dict[str, Any], str]], str]:
|
||||
"""Choose the body parser and declared body format."""
|
||||
if normalized_content_type in {"application/json"} or normalized_content_type.endswith("+json"):
|
||||
return self._parse_json_body, RESPONSE_FORMAT_JSON
|
||||
return self._parse_text_body, RESPONSE_FORMAT_TEXT
|
||||
if normalized_content_type in {MIMETYPE_APPLICATION_JSON} or normalized_content_type.endswith("+json"):
|
||||
return self._parse_json_body, REQUEST_RESPONSE_FORMAT_JSON
|
||||
return self._parse_text_body, REQUEST_RESPONSE_FORMAT_TEXT
|
||||
|
||||
@staticmethod
|
||||
def _accepts_json_response(headers: dict[str, str]) -> bool:
|
||||
@@ -742,16 +772,16 @@ class AgentFunctionApp(DFAppBase):
|
||||
|
||||
for value in accept_header.split(","):
|
||||
media_type = value.split(";")[0].strip().lower()
|
||||
if media_type == "application/json":
|
||||
if media_type == MIMETYPE_APPLICATION_JSON:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _select_response_format(body_format: str, prefers_json: bool) -> str:
|
||||
def _select_request_response_format(body_format: str, prefers_json: bool) -> str:
|
||||
"""Combine body format and accept preference to determine response format."""
|
||||
if body_format == RESPONSE_FORMAT_JSON or prefers_json:
|
||||
return RESPONSE_FORMAT_JSON
|
||||
return RESPONSE_FORMAT_TEXT
|
||||
if body_format == REQUEST_RESPONSE_FORMAT_JSON or prefers_json:
|
||||
return REQUEST_RESPONSE_FORMAT_JSON
|
||||
return REQUEST_RESPONSE_FORMAT_TEXT
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_body(req: func.HttpRequest) -> tuple[dict[str, Any], str]:
|
||||
|
||||
@@ -6,8 +6,6 @@ This module enables callers of AgentFunctionApp to supply streaming and final-re
|
||||
invoked during durable entity execution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Constants for Azure Functions Agent Framework integration."""
|
||||
|
||||
# Supported request/response formats and MIME types
|
||||
REQUEST_RESPONSE_FORMAT_JSON: str = "json"
|
||||
REQUEST_RESPONSE_FORMAT_TEXT: str = "text"
|
||||
MIMETYPE_APPLICATION_JSON: str = "application/json"
|
||||
MIMETYPE_TEXT_PLAIN: str = "text/plain"
|
||||
|
||||
# Field and header names
|
||||
THREAD_ID_FIELD: str = "thread_id"
|
||||
THREAD_ID_HEADER: str = "x-ms-thread-id"
|
||||
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
|
||||
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
|
||||
|
||||
# Polling configuration
|
||||
DEFAULT_MAX_POLL_RETRIES: int = 30
|
||||
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
|
||||
+1192
File diff suppressed because it is too large
Load Diff
@@ -11,14 +11,30 @@ import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import AsyncIterable, Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
from agent_framework import AgentProtocol, AgentRunResponse, AgentRunResponseUpdate, Role, get_logger
|
||||
from agent_framework import (
|
||||
AgentProtocol,
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
ChatMessage,
|
||||
ErrorContent,
|
||||
Role,
|
||||
get_logger,
|
||||
)
|
||||
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._durable_agent_state import (
|
||||
DurableAgentState,
|
||||
DurableAgentStateData,
|
||||
DurableAgentStateEntry,
|
||||
DurableAgentStateMessage,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateResponse,
|
||||
)
|
||||
from ._models import AgentResponse, RunRequest
|
||||
from ._state import AgentState
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.entities")
|
||||
|
||||
@@ -38,11 +54,11 @@ class AgentEntity:
|
||||
|
||||
Attributes:
|
||||
agent: The AgentProtocol instance
|
||||
state: The AgentState managing conversation history
|
||||
state: The DurableAgentState managing conversation history
|
||||
"""
|
||||
|
||||
agent: AgentProtocol
|
||||
state: AgentState
|
||||
state: DurableAgentState
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -56,11 +72,27 @@ class AgentEntity:
|
||||
callback: Optional callback invoked during streaming updates and final responses
|
||||
"""
|
||||
self.agent = agent
|
||||
self.state = AgentState()
|
||||
self.state = DurableAgentState()
|
||||
self.callback = callback
|
||||
|
||||
logger.debug(f"[AgentEntity] Initialized with agent type: {type(agent).__name__}")
|
||||
|
||||
def _is_error_response(self, entry: DurableAgentStateEntry) -> bool:
|
||||
"""Check if a conversation history entry is an error response.
|
||||
|
||||
Error responses should be kept in history for tracking but not sent to the agent
|
||||
since Azure OpenAI doesn't support 'error' content type.
|
||||
|
||||
Args:
|
||||
entry: A conversation history entry (DurableAgentStateEntry or dict)
|
||||
|
||||
Returns:
|
||||
True if the entry is a response containing error content, False otherwise
|
||||
"""
|
||||
if isinstance(entry, DurableAgentStateResponse):
|
||||
return entry.is_error
|
||||
return False
|
||||
|
||||
async def run_agent(
|
||||
self,
|
||||
context: df.DurableEntityContext,
|
||||
@@ -94,26 +126,27 @@ class AgentEntity:
|
||||
raise ValueError("RunRequest must include a thread_id")
|
||||
if not correlation_id:
|
||||
raise ValueError("RunRequest must include a correlation_id")
|
||||
role = run_request.role or Role.USER
|
||||
response_format = run_request.response_format
|
||||
enable_tool_calls = run_request.enable_tool_calls
|
||||
|
||||
logger.debug(f"[AgentEntity.run_agent] Received message: {message}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Thread ID: {thread_id}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Correlation ID: {correlation_id}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Role: {role.value}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Enable tool calls: {enable_tool_calls}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Response format: {'provided' if response_format else 'none'}")
|
||||
state_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
self.state.data.conversation_history.append(state_request)
|
||||
|
||||
# Store message in history with role
|
||||
self.state.add_user_message(message, role=role, correlation_id=correlation_id)
|
||||
|
||||
logger.debug("[AgentEntity.run_agent] Executing agent...")
|
||||
logger.debug(f"[AgentEntity.run_agent] Received Message: {state_request}")
|
||||
|
||||
try:
|
||||
logger.debug("[AgentEntity.run_agent] Starting agent invocation")
|
||||
|
||||
run_kwargs: dict[str, Any] = {"messages": self.state.get_chat_messages()}
|
||||
# Build messages from conversation history, excluding error responses
|
||||
# Error responses are kept in history for tracking but not sent to the agent
|
||||
chat_messages: list[ChatMessage] = [
|
||||
m.to_chat_message()
|
||||
for entry in self.state.data.conversation_history
|
||||
if not self._is_error_response(entry)
|
||||
for m in entry.messages
|
||||
]
|
||||
|
||||
run_kwargs: dict[str, Any] = {"messages": chat_messages}
|
||||
if not enable_tool_calls:
|
||||
run_kwargs["tools"] = None
|
||||
if response_format:
|
||||
@@ -133,8 +166,8 @@ class AgentEntity:
|
||||
|
||||
response_text = None
|
||||
structured_response = None
|
||||
|
||||
response_str: str | None = None
|
||||
|
||||
try:
|
||||
if response_format:
|
||||
try:
|
||||
@@ -156,18 +189,19 @@ class AgentEntity:
|
||||
)
|
||||
response_text = "Error extracting response"
|
||||
|
||||
state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response)
|
||||
self.state.data.conversation_history.append(state_response)
|
||||
|
||||
agent_response = AgentResponse(
|
||||
response=response_text,
|
||||
message=str(message),
|
||||
thread_id=str(thread_id),
|
||||
status="success",
|
||||
message_count=self.state.message_count,
|
||||
message_count=len(self.state.data.conversation_history),
|
||||
structured_response=structured_response,
|
||||
)
|
||||
result = agent_response.to_dict()
|
||||
|
||||
content = json.dumps(structured_response) if structured_response else (response_text or "")
|
||||
self.state.add_assistant_message(content, agent_run_response, correlation_id)
|
||||
logger.debug("[AgentEntity.run_agent] AgentRunResponse stored in conversation history")
|
||||
|
||||
return result
|
||||
@@ -181,12 +215,28 @@ class AgentEntity:
|
||||
logger.error(f"Error type: {type(exc).__name__}")
|
||||
logger.error(f"Full traceback:\n{error_traceback}")
|
||||
|
||||
# Create error message
|
||||
error_message = DurableAgentStateMessage.from_chat_message(
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT, contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)]
|
||||
)
|
||||
)
|
||||
|
||||
# Create and store error response in conversation history
|
||||
error_state_response = DurableAgentStateResponse(
|
||||
correlation_id=correlation_id,
|
||||
created_at=datetime.now(tz=timezone.utc),
|
||||
messages=[error_message],
|
||||
is_error=True,
|
||||
)
|
||||
self.state.data.conversation_history.append(error_state_response)
|
||||
|
||||
error_response = AgentResponse(
|
||||
response=f"Error: {exc!s}",
|
||||
message=str(message),
|
||||
thread_id=str(thread_id),
|
||||
status="error",
|
||||
message_count=self.state.message_count,
|
||||
message_count=len(self.state.data.conversation_history),
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
@@ -333,7 +383,7 @@ class AgentEntity:
|
||||
def reset(self, context: df.DurableEntityContext) -> None:
|
||||
"""Reset the entity state (clear conversation history)."""
|
||||
logger.debug("[AgentEntity.reset] Resetting entity state")
|
||||
self.state.reset()
|
||||
self.state.data = DurableAgentStateData(conversation_history=[])
|
||||
logger.debug("[AgentEntity.reset] State reset complete")
|
||||
|
||||
|
||||
@@ -362,7 +412,7 @@ def create_agent_entity(
|
||||
entity = AgentEntity(agent, callback)
|
||||
|
||||
if current_state is not None:
|
||||
entity.state.restore_state(current_state)
|
||||
entity.state = DurableAgentState.from_dict(current_state)
|
||||
logger.debug(
|
||||
"[entity_function] Restored entity from state (message_count: %s)", entity.state.message_count
|
||||
)
|
||||
@@ -392,8 +442,9 @@ def create_agent_entity(
|
||||
logger.error("[entity_function] Unknown operation: %s", operation)
|
||||
context.set_result({"error": f"Unknown operation: {operation}"})
|
||||
|
||||
logger.debug("State dict: %s", entity.state.to_dict())
|
||||
context.set_state(entity.state.to_dict())
|
||||
logger.debug(f"[entity_function] Operation {operation} completed successfully")
|
||||
logger.info(f"[entity_function] Operation {operation} completed successfully")
|
||||
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
"""Custom exception types for the durable agent framework."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class IncomingRequestError(ValueError):
|
||||
"""Raised when an incoming HTTP request cannot be parsed or validated."""
|
||||
|
||||
@@ -17,6 +17,8 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
import azure.durable_functions as df
|
||||
from agent_framework import AgentThread, Role
|
||||
|
||||
from ._constants import REQUEST_RESPONSE_FORMAT_TEXT
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type checking imports only
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -278,35 +280,43 @@ class RunRequest:
|
||||
|
||||
Attributes:
|
||||
message: The message to send to the agent
|
||||
request_response_format: The desired response format (e.g., "text" or "json")
|
||||
role: The role of the message sender (user, system, or assistant)
|
||||
response_format: Optional Pydantic BaseModel type describing the structured response format
|
||||
enable_tool_calls: Whether to enable tool calls for this request
|
||||
thread_id: Optional thread ID for tracking
|
||||
correlation_id: Optional correlation ID for tracking the response to this specific request
|
||||
created_at: Optional timestamp when the request was created
|
||||
"""
|
||||
|
||||
message: str
|
||||
request_response_format: str
|
||||
role: Role = Role.USER
|
||||
response_format: type[BaseModel] | None = None
|
||||
enable_tool_calls: bool = True
|
||||
thread_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
request_response_format: str = REQUEST_RESPONSE_FORMAT_TEXT,
|
||||
role: Role | str | None = Role.USER,
|
||||
response_format: type[BaseModel] | None = None,
|
||||
enable_tool_calls: bool = True,
|
||||
thread_id: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
created_at: str | None = None,
|
||||
) -> None:
|
||||
self.message = message
|
||||
self.role = self.coerce_role(role)
|
||||
self.response_format = response_format
|
||||
self.request_response_format = request_response_format
|
||||
self.enable_tool_calls = enable_tool_calls
|
||||
self.thread_id = thread_id
|
||||
self.correlation_id = correlation_id
|
||||
self.created_at = created_at
|
||||
|
||||
@staticmethod
|
||||
def coerce_role(value: Role | str | None) -> Role:
|
||||
@@ -326,13 +336,17 @@ class RunRequest:
|
||||
"message": self.message,
|
||||
"enable_tool_calls": self.enable_tool_calls,
|
||||
"role": self.role.value,
|
||||
"request_response_format": self.request_response_format,
|
||||
}
|
||||
if self.response_format:
|
||||
result["response_format"] = _serialize_response_format(self.response_format)
|
||||
if self.thread_id:
|
||||
result["thread_id"] = self.thread_id
|
||||
if self.correlation_id:
|
||||
result["correlation_id"] = self.correlation_id
|
||||
result["correlationId"] = self.correlation_id
|
||||
if self.created_at:
|
||||
result["created_at"] = self.created_at
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@@ -340,11 +354,13 @@ class RunRequest:
|
||||
"""Create RunRequest from dictionary."""
|
||||
return cls(
|
||||
message=data.get("message", ""),
|
||||
request_response_format=data.get("request_response_format", REQUEST_RESPONSE_FORMAT_TEXT),
|
||||
role=cls.coerce_role(data.get("role")),
|
||||
response_format=_deserialize_response_format(data.get("response_format")),
|
||||
enable_tool_calls=data.get("enable_tool_calls", True),
|
||||
thread_id=data.get("thread_id"),
|
||||
correlation_id=data.get("correlation_id"),
|
||||
correlation_id=data.get("correlationId"),
|
||||
created_at=data.get("created_at"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user