Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/dotnet-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ jobs:
- 'dotnet/src/Microsoft.Agents.AI.Workflows/**'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/**'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/**'
- 'dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/**'
- 'dotnet/Directory.Packages.props'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
- '.github/workflows/dotnet-build-and-test.yml'
Expand Down Expand Up @@ -404,6 +405,12 @@ jobs:
env:
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.IT_HOSTED_AGENT_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME }}
# Azure AI Search (for the azure-search-rag scenario). Reuses the integration
# environment secrets shared with python-sample-validation.yml. The index is
# provisioned out of band; see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
# for the required schema and seed content.
AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }}
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.

# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
Expand Down
1 change: 1 addition & 0 deletions dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.53.0" />
Expand Down
3 changes: 3 additions & 0 deletions dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj" />
</Folder>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_SEARCH_ENDPOINT=<your-azure-search-endpoint>
AZURE_SEARCH_INDEX_NAME=contoso-outdoors
AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential
AZURE_BEARER_TOKEN_SEARCH=DefaultAzureCredential
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish

# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedAzureSearchRag.dll"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
# which means a standard multi-stage Docker build cannot resolve dependencies outside
# this folder. Instead, pre-publish the app targeting the container runtime and copy
# the output into the container:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-azure-search-rag .
# docker run --rm -p 8088:8088 \
# -e AGENT_NAME=hosted-azure-search-rag \
# -e AZURE_BEARER_TOKEN_FOUNDRY=$AZURE_BEARER_TOKEN_FOUNDRY \
# -e AZURE_BEARER_TOKEN_SEARCH=$AZURE_BEARER_TOKEN_SEARCH \
# --env-file .env hosted-azure-search-rag
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedAzureSearchRag.dll"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedAzureSearchRag</RootNamespace>
<AssemblyName>HostedAzureSearchRag</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>

<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>

<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
</ItemGroup>
-->

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright (c) Microsoft. All rights reserved.

// This sample shows how to add Retrieval Augmented Generation (RAG) capabilities to a hosted
// agent using Azure AI Search. The sample assumes the search index has already been provisioned
// and populated out of band (see README.md for the required schema and example seed content).
// A SearchClient-backed adapter is plugged into TextSearchProvider, which runs a keyword search
// against the index before each model invocation and injects the matching documents into the
// model context.

using Azure;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using OpenAI.Chat;

// Load .env file if present (for local development)
Env.TraversePath().Load();

string projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";

string searchEndpoint = Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set.");
string searchIndexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set.");

// Use a chained credential. Try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in
// production). The dev credential is scope aware so a single instance serves both Foundry and
// Azure AI Search clients (each Azure SDK client requests a token for its own audience).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());

// Connect to the pre-provisioned search index. The caller is expected to have created the
// index and populated it with documents matching the schema (id / content / sourceName /
// sourceLink) before running this sample. See README.md for an example provisioning script.
var searchClient = new SearchClient(new Uri(searchEndpoint), searchIndexName, credential);

TextSearchProviderOptions textSearchOptions = new()
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};

AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
.AsAIAgent(new ChatClientAgentOptions
{
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-azure-search-rag",
ChatOptions = new ChatOptions
{
ModelId = deploymentName,
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
"Answer questions using the provided context and cite the source document when available.",
},
AIContextProviders = [new TextSearchProvider(CreateSearchAdapter(searchClient), textSearchOptions)]
});

// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);

var app = builder.Build();
app.MapFoundryResponses();

if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}

app.Run();

// ── Search adapter ───────────────────────────────────────────────────────────
// Wraps a SearchClient as the delegate TextSearchProvider expects. Keyword/full-text only;
// no embeddings. Returns the top results and projects them into TextSearchResult entries
// the provider will inject into the model context.

static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
CreateSearchAdapter(SearchClient client, int top = 3) =>
async (query, cancellationToken) =>
{
var options = new SearchOptions { Size = top };
Response<SearchResults<SearchDocument>> response =
await client.SearchAsync<SearchDocument>(query, options, cancellationToken).ConfigureAwait(false);

var results = new List<TextSearchProvider.TextSearchResult>();
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
{
results.Add(new TextSearchProvider.TextSearchResult
{
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
RawRepresentation = hit
});
}

return results;
};

/// <summary>
/// A scope aware <see cref="TokenCredential"/> for local Docker debugging only.
/// Reads pre-fetched bearer tokens from environment variables, dispensing the right token
/// based on the requested scope:
/// <list type="bullet">
/// <item><description><c>ai.azure.com</c> scopes -> <c>AZURE_BEARER_TOKEN_FOUNDRY</c></description></item>
/// <item><description><c>search.azure.com</c> scopes -> <c>AZURE_BEARER_TOKEN_SEARCH</c></description></item>
/// </list>
/// For any other scope, throws <see cref="CredentialUnavailableException"/> so a chained
/// credential will fall through. This should NOT be used in production: tokens expire (~1 hour)
/// and cannot be refreshed.
///
/// Generate the tokens on your host and pass them to the container:
/// <code>
/// export AZURE_BEARER_TOKEN_FOUNDRY=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
/// export AZURE_BEARER_TOKEN_SEARCH=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
/// docker run -e AZURE_BEARER_TOKEN_FOUNDRY -e AZURE_BEARER_TOKEN_SEARCH ...
/// </code>
/// </summary>
internal sealed class DevTemporaryTokenCredential : TokenCredential
{
private const string FoundryEnvironmentVariable = "AZURE_BEARER_TOKEN_FOUNDRY";
private const string SearchEnvironmentVariable = "AZURE_BEARER_TOKEN_SEARCH";

public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> Resolve(requestContext.Scopes);

public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> new(Resolve(requestContext.Scopes));

private static AccessToken Resolve(IReadOnlyList<string> scopes)
{
string? envVar = null;
foreach (var scope in scopes)
{
if (scope.Contains("search.azure.com", StringComparison.OrdinalIgnoreCase))
{
envVar = SearchEnvironmentVariable;
break;
}

if (scope.Contains("ai.azure.com", StringComparison.OrdinalIgnoreCase))
{
envVar = FoundryEnvironmentVariable;
break;
}
}

if (envVar is null)
{
throw new CredentialUnavailableException(
$"DevTemporaryTokenCredential cannot serve scopes [{string.Join(", ", scopes)}]; falling through.");
}

var token = Environment.GetEnvironmentVariable(envVar);
if (string.IsNullOrEmpty(token) || string.Equals(token, "DefaultAzureCredential", StringComparison.Ordinal))
{
throw new CredentialUnavailableException(
$"{envVar} environment variable is not set; falling through to next credential.");
}

return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1));
}
Comment thread
rogerbarreto marked this conversation as resolved.
}
Loading
Loading