mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into peibekwe/workflow-asagent-fix
This commit is contained in:
@@ -58,6 +58,8 @@
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
|
||||
<!-- Microsoft.AspNetCore.* -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
<Folder Name="/Samples/02-agents/AgentSkills/">
|
||||
<File Path="samples/02-agents/AgentSkills/README.md" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step02_ScriptExecutionWithCodeInterpreter/Agent_Step02_ScriptExecutionWithCodeInterpreter.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
||||
@@ -288,6 +287,12 @@
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/AspNetAgentAuthorization/">
|
||||
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml" />
|
||||
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/README.md" />
|
||||
<Project Path="samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj" />
|
||||
<Project Path="samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".editorconfig" />
|
||||
<File Path=".gitignore" />
|
||||
|
||||
@@ -22,9 +22,6 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM
|
||||
var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills"));
|
||||
|
||||
// --- Agent Setup ---
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.GetResponsesClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use Agent Skills with script execution via the hosted code interpreter.
|
||||
// When FileAgentSkillScriptExecutor.HostedCodeInterpreter() is configured, the agent can load and execute scripts
|
||||
// from skill resources using the LLM provider's built-in code interpreter.
|
||||
//
|
||||
// This sample includes the password-generator skill:
|
||||
// - A Python script for generating secure passwords
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
// --- Configuration ---
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// --- Skills Provider with Script Execution ---
|
||||
// Discovers skills and enables script execution via the hosted code interpreter
|
||||
var skillsProvider = new FileAgentSkillsProvider(
|
||||
skillPath: Path.Combine(AppContext.BaseDirectory, "skills"),
|
||||
options: new FileAgentSkillsProviderOptions
|
||||
{
|
||||
ScriptExecutor = FileAgentSkillScriptExecutor.HostedCodeInterpreter()
|
||||
});
|
||||
|
||||
// --- Agent Setup ---
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.GetResponsesClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "SkillsAgent",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a helpful assistant that can generate secure passwords.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
});
|
||||
|
||||
// --- Example: Password generation with script execution ---
|
||||
Console.WriteLine("Example: Generating a password with a skill script");
|
||||
Console.WriteLine("---------------------------------------------------");
|
||||
AgentResponse response = await agent.RunAsync("Generate a secure password for my database account.");
|
||||
Console.WriteLine($"Agent: {response.Text}\n");
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
# Script Execution with Code Interpreter
|
||||
|
||||
This sample demonstrates how to use **Agent Skills** with **script execution** via the hosted code interpreter.
|
||||
|
||||
## What's Different from Step01?
|
||||
|
||||
In the [basic skills sample](../Agent_Step01_BasicSkills/), skills only provide instructions and resources as text. This sample adds **script execution** — the agent can load Python scripts from skill resources and execute them using the LLM provider's built-in code interpreter.
|
||||
|
||||
This is enabled by configuring `FileAgentSkillScriptExecutor.HostedCodeInterpreter()` on the skills provider options:
|
||||
|
||||
```csharp
|
||||
var skillsProvider = new FileAgentSkillsProvider(
|
||||
skillPath: Path.Combine(AppContext.BaseDirectory, "skills"),
|
||||
options: new FileAgentSkillsProviderOptions
|
||||
{
|
||||
ScriptExecutor = FileAgentSkillScriptExecutor.HostedCodeInterpreter()
|
||||
});
|
||||
```
|
||||
|
||||
## Skills Included
|
||||
|
||||
### password-generator
|
||||
Generates secure passwords using a Python script with configurable length and complexity.
|
||||
- `scripts/generate.py` — Password generation script
|
||||
- `references/PASSWORD_GUIDELINES.md` — Recommended length and symbol sets by use case
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Agent_Step02_ScriptExecutionWithCodeInterpreter/
|
||||
├── Program.cs
|
||||
├── Agent_Step02_ScriptExecutionWithCodeInterpreter.csproj
|
||||
└── skills/
|
||||
└── password-generator/
|
||||
├── SKILL.md
|
||||
├── scripts/
|
||||
│ └── generate.py
|
||||
└── references/
|
||||
└── PASSWORD_GUIDELINES.md
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Prerequisites
|
||||
- .NET 10.0 SDK
|
||||
- Azure OpenAI endpoint with a deployed model that supports code interpreter
|
||||
|
||||
### Setup
|
||||
1. Set environment variables:
|
||||
```bash
|
||||
export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
|
||||
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
|
||||
```
|
||||
|
||||
2. Run the sample:
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
The sample asks the agent to generate a secure password. The agent:
|
||||
1. Loads the password-generator skill
|
||||
2. Reads the `generate.py` script via `read_skill_resource`
|
||||
3. Executes the script using the code interpreter with appropriate parameters
|
||||
4. Returns the generated password
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Agent Skills Specification](https://agentskills.io/)
|
||||
- [Step01: Basic Skills](../Agent_Step01_BasicSkills/) — Skills without script execution
|
||||
- [Microsoft Agent Framework Documentation](../../../../../docs/)
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
---
|
||||
name: password-generator
|
||||
description: Generate secure passwords using a Python script. Use when asked to create passwords or credentials.
|
||||
---
|
||||
|
||||
# Password Generator
|
||||
|
||||
This skill generates secure passwords using a Python script.
|
||||
|
||||
## Usage
|
||||
|
||||
When the user requests a password:
|
||||
1. First, review `references/PASSWORD_GUIDELINES.md` to determine the recommended password length and character sets for the user's use case
|
||||
2. Load `scripts/generate.py` and adjust its parameters (length, character set) based on the guidelines and user's requirements
|
||||
3. Execute the script
|
||||
4. Present the generated password clearly
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
# Password Generation Guidelines
|
||||
|
||||
## General Rules
|
||||
|
||||
- Never reuse passwords across services.
|
||||
- Always use cryptographically secure randomness (e.g., `random.SystemRandom()`).
|
||||
- Avoid dictionary words, keyboard patterns, and personal information.
|
||||
|
||||
## Recommended Settings by Use Case
|
||||
|
||||
| Use Case | Min Length | Character Set | Example |
|
||||
|-----------------------|-----------|----------------------------------------|--------------------------|
|
||||
| Web account | 16 | Upper + lower + digits + symbols | `G7!kQp@2xM#nW9$z` |
|
||||
| Database credential | 24 | Upper + lower + digits + symbols | `aR3$vK8!mN2@pQ7&xL5#wY` |
|
||||
| Wi-Fi / network key | 20 | Upper + lower + digits + symbols | `Ht4&jL9!rP2#mK7@xQ` |
|
||||
| API key / token | 32 | Upper + lower + digits (no symbols) | `k8Rm3xQ7nW2pL9vT4jH6yA` |
|
||||
| Encryption passphrase | 32 | Upper + lower + digits + symbols | `Xp4!kR8@mN2#vQ7&jL9$wT` |
|
||||
|
||||
## Symbol Sets
|
||||
|
||||
- **Standard symbols**: `!@#$%^&*()-_=+`
|
||||
- **Extended symbols**: `~`{}[]|;:'",.<>?/\`
|
||||
- **Safe symbols** (URL/shell-safe): `!@#$&*-_=+`
|
||||
- If the target system restricts symbols, use only the **safe** set.
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
# Password generator script
|
||||
# Usage: Adjust 'length' as needed, then run
|
||||
|
||||
import random
|
||||
import string
|
||||
|
||||
length = 16 # desired length
|
||||
|
||||
pool = string.ascii_lowercase + string.ascii_uppercase + string.digits + string.punctuation
|
||||
password = "".join(random.SystemRandom().choice(pool) for _ in range(length))
|
||||
print(f"Generated password ({length} chars): {password}")
|
||||
@@ -5,4 +5,3 @@ Samples demonstrating Agent Skills capabilities.
|
||||
| Sample | Description |
|
||||
|--------|-------------|
|
||||
| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources |
|
||||
| [Agent_Step02_ScriptExecutionWithCodeInterpreter](Agent_Step02_ScriptExecutionWithCodeInterpreter/) | Using Agent Skills with script execution via the hosted code interpreter |
|
||||
|
||||
@@ -72,6 +72,8 @@ public static class Program
|
||||
await RunWorkflowAsync(
|
||||
AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 5 })
|
||||
.AddParticipants(from lang in (string[])["French", "Spanish", "English"] select GetTranslationAgent(lang, client))
|
||||
.WithName("Translation Round Robin Workflow")
|
||||
.WithDescription("A workflow where three translation agents take turns responding in a round-robin fashion.")
|
||||
.Build(),
|
||||
[new(ChatRole.User, "Hello, world!")]);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Auth Client-Server Sample
|
||||
|
||||
This sample demonstrates how to authorize AI agents and their tools using OAuth 2.0 scopes. It shows two levels of access control: an endpoint-level scope (`agent.chat`) that gates access to the agent, and tool-level scopes (`expenses.view`, `expenses.approve`) that control what the agent can do on behalf of each user.
|
||||
|
||||
While this sample uses Keycloak to avoid complex setup in order to run the sample, Keycloak can easily be replaced with any OIDC compatible provider, including [Microsoft Entra Id](https://www.microsoft.com/security/business/identity-access/microsoft-entra-id).
|
||||
|
||||
## Overview
|
||||
|
||||
The sample has three components, all launched with a single `docker compose up`:
|
||||
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| **WebClient** | `http://localhost:8080` | Razor Pages web app with OIDC login and a chat UI that calls the AgentService |
|
||||
| **AgentService** | `http://localhost:5001` | ASP.NET Minimal API hosting an expense approval agent with scope-authorized tools |
|
||||
| **Keycloak** | `http://localhost:5002` | OIDC identity provider, auto-provisioned with realm, clients, scopes, and test users |
|
||||
|
||||
```
|
||||
┌──────────────┐ OIDC login ┌───────────┐
|
||||
│ WebClient │ ◄──────────────────► │ Keycloak │
|
||||
│ (Razor app) │ (browser flow) │ (Docker) │
|
||||
│ :8080 │ │ :5002 │
|
||||
└──────┬───────┘ └─────┬─────┘
|
||||
│ REST + Bearer token │
|
||||
▼ │
|
||||
┌───────────────┐ JWT validation ──────┘
|
||||
│ AgentService │ ◄──── (jwks from Keycloak)
|
||||
│ (Minimal API) │
|
||||
│ :5001 │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||
|
||||
## Configuring Environment Variables
|
||||
|
||||
The AgentService requires an OpenAI-compatible endpoint. Set these environment variables before running:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="<your-openai-api-key>"
|
||||
export OPENAI_MODEL="gpt-4.1-mini"
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Option 1: Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/05-end-to-end/AspNetAgentAuthorization
|
||||
docker compose up
|
||||
```
|
||||
|
||||
This starts Keycloak, the AgentService, and the WebClient. Wait for Keycloak to finish importing the realm (you'll see `Running the server` in the logs).
|
||||
|
||||
#### Running in GitHub Codespaces
|
||||
|
||||
This sample has been built in such a way that it can be run from GitHub Codespaces.
|
||||
The Agent Framework repository has a C# specific dev container, named "C# (.NET)", that is configured for Codespaces.
|
||||
|
||||
When running in Codespaces, the sample auto-detects the environment via
|
||||
`CODESPACE_NAME` and `GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN` and configures
|
||||
Keycloak and the web client accordingly. Just make the required ports public:
|
||||
|
||||
```bash
|
||||
# Make Keycloak and WebClient ports publicly accessible
|
||||
gh codespace ports visibility 5002:public 8080:public -c $CODESPACE_NAME
|
||||
|
||||
# Start the containers (Codespaces is auto-detected)
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Then open the Codespaces-forwarded URL for port 8080 (shown in the **Ports** tab) in your browser.
|
||||
|
||||
### Option 2: Run Locally
|
||||
|
||||
1. Start Keycloak:
|
||||
```bash
|
||||
docker compose up keycloak
|
||||
```
|
||||
|
||||
2. In a new terminal, start the AgentService:
|
||||
```bash
|
||||
cd Service
|
||||
dotnet run --urls "http://localhost:5001"
|
||||
```
|
||||
|
||||
3. In another terminal, start the WebClient:
|
||||
```bash
|
||||
cd RazorWebClient
|
||||
dotnet run --urls "http://localhost:8080"
|
||||
```
|
||||
|
||||
## Using the Sample
|
||||
|
||||
1. Open `http://localhost:8080` in your browser
|
||||
2. Click **Login** — you'll be redirected to Keycloak
|
||||
3. Sign in with one of the pre-configured users:
|
||||
- **`testuser` / `password`** — can chat, view expenses, and approve expenses (up to €1,000)
|
||||
- **`viewer` / `password`** — can chat and view expenses, but **cannot approve** them
|
||||
4. Try asking the agent:
|
||||
- _"Show me the pending expenses"_ — both users can do this
|
||||
- _"Approve expense #1"_ — only `testuser` can do this; `viewer` will be denied
|
||||
- _"Approve expense #3"_ — even `testuser` will be denied (€4,500 exceeds the €1,000 limit)
|
||||
|
||||
## Pre-Configured Keycloak Realm
|
||||
|
||||
The `keycloak/dev-realm.json` file auto-provisions:
|
||||
|
||||
| Resource | Details |
|
||||
|----------|---------|
|
||||
| **Realm** | `dev` |
|
||||
| **Client: agent-service** | Confidential client (the API audience) |
|
||||
| **Client: web-client** | Public client for the Razor app's OIDC login |
|
||||
| **Scope: agent.chat** | Required to call the `/chat` endpoint |
|
||||
| **Scope: expenses.view** | Required to list pending expenses |
|
||||
| **Scope: expenses.approve** | Required to approve expenses |
|
||||
| **User: testuser** | Has `agent.chat`, `expenses.view`, and `expenses.approve` scopes |
|
||||
| **User: viewer** | Has `agent.chat` and `expenses.view` scopes (no approval) |
|
||||
|
||||
### Pre-Seeded Expenses
|
||||
|
||||
The service starts with five demo expenses:
|
||||
|
||||
| # | Description | Amount | Status |
|
||||
|---|-------------|--------|--------|
|
||||
| 1 | Conference travel — Berlin | €850 | Pending |
|
||||
| 2 | Team dinner — Q4 celebration | €320 | Pending |
|
||||
| 3 | Cloud infrastructure — annual renewal | €4,500 | Pending (over limit) |
|
||||
| 4 | Office supplies — ergonomic keyboards | €675 | Pending |
|
||||
| 5 | Client gift baskets — holiday season | €980 | Pending |
|
||||
|
||||
Keycloak admin console: `http://localhost:5002` (login: `admin` / `admin`).
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /chat (requires `agent.chat` scope)
|
||||
|
||||
```bash
|
||||
# Get a token for testuser
|
||||
TOKEN=$(curl -s -X POST http://localhost:5002/realms/dev/protocol/openid-connect/token \
|
||||
-d "grant_type=password&client_id=web-client&username=testuser&password=password&scope=openid agent.chat expenses.view expenses.approve" \
|
||||
| jq -r '.access_token')
|
||||
|
||||
# Chat with the agent
|
||||
curl -X POST http://localhost:5001/chat \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Show me the pending expenses"}'
|
||||
```
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Endpoint-Level Authorization** — The `/chat` endpoint requires the `agent.chat` scope, gating access to the agent itself
|
||||
- **Tool-Level Authorization** — Each agent tool checks its own scope (`expenses.view`, `expenses.approve`) at runtime, so different users have different capabilities within the same chat session
|
||||
- **Scope-Based Role Mapping** — Keycloak realm roles map to OAuth scopes, allowing administrators to control which users can access which agent capabilities
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /repo
|
||||
|
||||
# Copy solution-level files for restore
|
||||
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json nuget.config ./
|
||||
COPY eng/ eng/
|
||||
COPY src/Shared/ src/Shared/
|
||||
COPY samples/Directory.Build.props samples/
|
||||
|
||||
# Create sentinel file so $(RepoRoot) resolves correctly inside the container.
|
||||
# RepoRoot is the parent of the dir containing CODE_OF_CONDUCT.md,
|
||||
# and src projects import $(RepoRoot)/dotnet/nuget/nuget-package.props.
|
||||
RUN touch /CODE_OF_CONDUCT.md
|
||||
|
||||
# Copy project file for restore
|
||||
COPY samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/
|
||||
|
||||
RUN dotnet restore samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false
|
||||
|
||||
# Copy everything and build
|
||||
COPY samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/ samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/
|
||||
RUN dotnet publish samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "RazorWebClient.dll"]
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
@page
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@model AspNetAgentAuthorization.RazorWebClient.Pages.ChatModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<h1>Chat with the Agent</h1>
|
||||
|
||||
<form method="post">
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<input type="text" name="message" value="@Model.Message" placeholder="Type your message..."
|
||||
style="flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px;" />
|
||||
<button type="submit"
|
||||
style="padding: 10px 20px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (Model.Error is not null)
|
||||
{
|
||||
<div style="background: #fee; border: 1px solid #fcc; border-radius: 4px; padding: 12px; margin-bottom: 12px; color: #c00;">
|
||||
<strong>Error:</strong> @Model.Error
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Reply is not null)
|
||||
{
|
||||
<div style="background: #f0f7ff; border: 1px solid #cce0ff; border-radius: 4px; padding: 12px; margin-bottom: 12px;">
|
||||
<div style="font-size: 12px; color: #666; margin-bottom: 4px;">Agent (responding to @Model.ReplyUser):</div>
|
||||
<div>@Model.Reply</div>
|
||||
</div>
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace AspNetAgentAuthorization.RazorWebClient.Pages;
|
||||
|
||||
public class ChatModel : PageModel
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public ChatModel(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
this._httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[BindProperty]
|
||||
public string? Message { get; set; }
|
||||
|
||||
public string? Reply { get; set; }
|
||||
public string? ReplyUser { get; set; }
|
||||
public string? Error { get; set; }
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
|
||||
public async Task OnPostAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this.Message))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get the access token stored during OIDC login
|
||||
string? accessToken = await this.HttpContext.GetTokenAsync("access_token");
|
||||
if (accessToken is null)
|
||||
{
|
||||
this.Error = "No access token available. Please log in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the AgentService with the Bearer token
|
||||
var client = this._httpClientFactory.CreateClient("AgentService");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var payload = JsonSerializer.Serialize(new { message = this.Message });
|
||||
var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await client.PostAsync(new Uri("/chat", UriKind.Relative), content);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
this.Reply = json.RootElement.GetProperty("reply").GetString();
|
||||
this.ReplyUser = json.RootElement.GetProperty("user").GetString();
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Error = response.StatusCode switch
|
||||
{
|
||||
System.Net.HttpStatusCode.Unauthorized => "Authentication failed (401). Your session may have expired.",
|
||||
System.Net.HttpStatusCode.Forbidden => "Access denied (403). Your account does not have the required 'agent.chat' scope.",
|
||||
_ => $"AgentService returned {(int)response.StatusCode} {response.ReasonPhrase}."
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Error = $"Failed to contact the AgentService: {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
@page
|
||||
@model AspNetAgentAuthorization.RazorWebClient.Pages.IndexModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<h1>Welcome</h1>
|
||||
<p>This sample demonstrates securing an AI agent API with OAuth 2.0 / OpenID Connect.</p>
|
||||
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<p>You are logged in as <strong>@User.Identity.Name</strong>.</p>
|
||||
<p><a href="/Chat">Go to Chat →</a></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Please <a href="/Chat">log in</a> to chat with the agent.</p>
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace AspNetAgentAuthorization.RazorWebClient.Pages;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
|
||||
public IActionResult OnGetLogout()
|
||||
{
|
||||
return this.SignOut(
|
||||
new AuthenticationProperties { RedirectUri = "/" },
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
OpenIdConnectDefaults.AuthenticationScheme);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Auth Agent Chat</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
|
||||
nav { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #ddd; margin-bottom: 20px; }
|
||||
nav a { text-decoration: none; color: #0066cc; margin-left: 10px; }
|
||||
.user-info { color: #666; }
|
||||
.container { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
h1 { color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<strong>🤖 Auth Agent Chat</strong>
|
||||
<div>
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<span class="user-info">@User.Identity.Name</span>
|
||||
<a href="/Index?handler=Logout">Logout</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/Chat">Login</a>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
@RenderBody()
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@namespace AspNetAgentAuthorization.RazorWebClient.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates an OIDC-authenticated Razor Pages web client
|
||||
// that calls a JWT-secured AI agent REST API.
|
||||
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRazorPages();
|
||||
|
||||
// Persist data protection keys so antiforgery tokens survive container rebuilds
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo("/app/keys"));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication: Cookie + OpenID Connect (Keycloak)
|
||||
// ---------------------------------------------------------------------------
|
||||
string authority = builder.Configuration["Auth:Authority"]
|
||||
?? throw new InvalidOperationException("Auth:Authority is not configured.");
|
||||
|
||||
// PublicKeycloakUrl is the browser-facing Keycloak base URL. When the
|
||||
// web-client runs inside Docker, Authority points to the internal hostname
|
||||
// (e.g. http://keycloak:8080) for backchannel discovery, while
|
||||
// PublicKeycloakUrl is what the browser can reach (e.g. http://localhost:5002).
|
||||
// When running outside Docker, Authority already IS the public URL and
|
||||
// PublicKeycloakUrl is not needed.
|
||||
string? publicKeycloakUrl = builder.Configuration["Auth:PublicKeycloakUrl"];
|
||||
|
||||
// In Codespaces, override the public URLs with the tunnel endpoints.
|
||||
string? codespaceName = Environment.GetEnvironmentVariable("CODESPACE_NAME");
|
||||
string? codespaceDomain = Environment.GetEnvironmentVariable("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN");
|
||||
bool isCodespaces = !string.IsNullOrEmpty(codespaceName) && !string.IsNullOrEmpty(codespaceDomain);
|
||||
if (isCodespaces)
|
||||
{
|
||||
publicKeycloakUrl = $"https://{codespaceName}-5002.{codespaceDomain}";
|
||||
}
|
||||
|
||||
// Derive the internal base URL from Authority for URL rewriting.
|
||||
string internalKeycloakBase = new Uri(authority).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie()
|
||||
.AddOpenIdConnect(options =>
|
||||
{
|
||||
options.Authority = authority;
|
||||
options.ClientId = builder.Configuration["Auth:ClientId"]
|
||||
?? throw new InvalidOperationException("Auth:ClientId is not configured.");
|
||||
|
||||
options.ResponseType = OpenIdConnectResponseType.Code;
|
||||
options.SaveTokens = true;
|
||||
options.GetClaimsFromUserInfoEndpoint = true;
|
||||
|
||||
// Request scopes so the access token includes them
|
||||
options.Scope.Clear();
|
||||
options.Scope.Add("openid");
|
||||
options.Scope.Add("profile");
|
||||
options.Scope.Add("email");
|
||||
options.Scope.Add("agent.chat");
|
||||
options.Scope.Add("expenses.view");
|
||||
options.Scope.Add("expenses.approve");
|
||||
|
||||
// For local development with HTTP-only Keycloak
|
||||
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
|
||||
|
||||
// When the web-client is inside Docker, the backchannel Authority uses
|
||||
// an internal hostname that differs from the browser-facing URL.
|
||||
// Rewrite the authorization/logout endpoints so the browser is
|
||||
// redirected to the public Keycloak URL, and disable issuer validation
|
||||
// because the token issuer (public URL) won't match the discovery
|
||||
// document issuer (internal URL).
|
||||
if (publicKeycloakUrl is not null)
|
||||
{
|
||||
#pragma warning disable CA5404 // Token issuer validation disabled: backchannel uses internal Docker hostname while tokens are issued via the public URL.
|
||||
options.TokenValidationParameters.ValidateIssuer = false;
|
||||
#pragma warning restore CA5404
|
||||
|
||||
// The UserInfo endpoint is on the internal URL but the token
|
||||
// issuer is the public URL — Keycloak rejects the mismatch.
|
||||
// The ID token already contains all needed claims.
|
||||
options.GetClaimsFromUserInfoEndpoint = false;
|
||||
|
||||
// In Codespaces the tunnel delivers with Host: localhost, so the
|
||||
// auto-generated redirect_uri is wrong. Override it explicitly.
|
||||
string? publicWebClientBase = isCodespaces
|
||||
? $"https://{codespaceName}-8080.{codespaceDomain}"
|
||||
: null;
|
||||
|
||||
options.Events = new OpenIdConnectEvents
|
||||
{
|
||||
OnRedirectToIdentityProvider = context =>
|
||||
{
|
||||
context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress
|
||||
.Replace(internalKeycloakBase, publicKeycloakUrl);
|
||||
if (publicWebClientBase is not null)
|
||||
{
|
||||
context.ProtocolMessage.RedirectUri = $"{publicWebClientBase}/signin-oidc";
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnRedirectToIdentityProviderForSignOut = context =>
|
||||
{
|
||||
context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress
|
||||
.Replace(internalKeycloakBase, publicKeycloakUrl);
|
||||
if (publicWebClientBase is not null)
|
||||
{
|
||||
context.ProtocolMessage.PostLogoutRedirectUri = $"{publicWebClientBase}/signout-callback-oidc";
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HttpClient for calling the AgentService — attaches Bearer token
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddHttpClient("AgentService", client =>
|
||||
{
|
||||
string baseUrl = builder.Configuration["AgentService:BaseUrl"] ?? "http://localhost:5001";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapRazorPages();
|
||||
|
||||
await app.RunAsync();
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"RazorWebClient": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:58080;http://localhost:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Auth": {
|
||||
"Authority": "http://localhost:5002/realms/dev",
|
||||
"ClientId": "web-client"
|
||||
},
|
||||
"AgentService": {
|
||||
"BaseUrl": "http://localhost:5001"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /repo
|
||||
|
||||
# Copy solution-level files for restore
|
||||
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json nuget.config ./
|
||||
COPY eng/ eng/
|
||||
COPY nuget/ nuget/
|
||||
COPY src/Shared/ src/Shared/
|
||||
COPY samples/Directory.Build.props samples/
|
||||
|
||||
# Create sentinel file so $(RepoRoot) resolves correctly inside the container.
|
||||
# RepoRoot is the parent of the dir containing CODE_OF_CONDUCT.md,
|
||||
# and src projects import $(RepoRoot)/dotnet/nuget/nuget-package.props.
|
||||
RUN touch /CODE_OF_CONDUCT.md && mkdir -p /dotnet/nuget && cp /repo/nuget/* /dotnet/nuget/
|
||||
|
||||
# Copy project files for restore
|
||||
COPY src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj src/Microsoft.Agents.AI.Abstractions/
|
||||
COPY src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj src/Microsoft.Agents.AI/
|
||||
COPY src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj src/Microsoft.Agents.AI.OpenAI/
|
||||
COPY samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj samples/05-end-to-end/AspNetAgentAuthorization/Service/
|
||||
|
||||
RUN dotnet restore samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false
|
||||
|
||||
# Copy everything and build
|
||||
COPY src/ src/
|
||||
COPY samples/05-end-to-end/AspNetAgentAuthorization/Service/ samples/05-end-to-end/AspNetAgentAuthorization/Service/
|
||||
RUN dotnet publish samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
ENV ASPNETCORE_URLS=http://+:5001
|
||||
EXPOSE 5001
|
||||
ENTRYPOINT ["dotnet", "Service.dll"]
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace AspNetAgentAuthorization.Service;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an expense awaiting approval.
|
||||
/// </summary>
|
||||
public sealed class Expense
|
||||
{
|
||||
public int Id { get; init; }
|
||||
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
public string Submitter { get; init; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "Pending";
|
||||
|
||||
public string? ApprovedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages expense approvals. Pre-seeded with demo data so there are
|
||||
/// expenses to review immediately. Uses <see cref="IUserContext"/> to
|
||||
/// identify the caller and enforce scope-based permissions.
|
||||
/// </summary>
|
||||
public sealed class ExpenseService
|
||||
{
|
||||
/// <summary>Maximum amount (EUR) that can be approved.</summary>
|
||||
private const decimal ApprovalLimit = 1000m;
|
||||
|
||||
private static readonly ConcurrentDictionary<int, Expense> s_expenses = new(
|
||||
new Dictionary<int, Expense>
|
||||
{
|
||||
[1] = new() { Id = 1, Description = "Conference travel — Berlin", Amount = 850m, Submitter = "Alice" },
|
||||
[2] = new() { Id = 2, Description = "Team dinner — Q4 celebration", Amount = 320m, Submitter = "Bob" },
|
||||
[3] = new() { Id = 3, Description = "Cloud infrastructure — annual renewal", Amount = 4500m, Submitter = "Carol" },
|
||||
[4] = new() { Id = 4, Description = "Office supplies — ergonomic keyboards", Amount = 675m, Submitter = "Dave" },
|
||||
[5] = new() { Id = 5, Description = "Client gift baskets — holiday season", Amount = 980m, Submitter = "Eve" },
|
||||
});
|
||||
|
||||
private readonly IUserContext _userContext;
|
||||
|
||||
public ExpenseService(IUserContext userContext)
|
||||
{
|
||||
this._userContext = userContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all pending expenses awaiting approval.
|
||||
/// </summary>
|
||||
[Description("Lists all pending expenses awaiting approval. Requires the expenses.view scope.")]
|
||||
public string ListPendingExpenses()
|
||||
{
|
||||
if (!this._userContext.Scopes.Contains("expenses.view"))
|
||||
{
|
||||
return "Access denied. You do not have the expenses.view scope.";
|
||||
}
|
||||
|
||||
var pending = s_expenses.Values
|
||||
.Where(e => e.Status == "Pending")
|
||||
.OrderBy(e => e.Id)
|
||||
.ToList();
|
||||
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
return "No pending expenses.";
|
||||
}
|
||||
|
||||
return string.Join("\n", pending.Select(e =>
|
||||
$"#{e.Id}: {e.Description} — €{e.Amount:N2} (submitted by {e.Submitter})"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approves a pending expense by its ID.
|
||||
/// </summary>
|
||||
[Description("Approves a pending expense by its ID. Requires the expenses.approve scope.")]
|
||||
public string ApproveExpense([Description("The ID of the expense to approve")] int expenseId)
|
||||
{
|
||||
if (!this._userContext.Scopes.Contains("expenses.approve"))
|
||||
{
|
||||
return "Access denied. You do not have the expenses.approve scope.";
|
||||
}
|
||||
|
||||
if (!s_expenses.TryGetValue(expenseId, out var expense))
|
||||
{
|
||||
return $"Expense #{expenseId} not found.";
|
||||
}
|
||||
|
||||
if (expense.Status != "Pending")
|
||||
{
|
||||
return $"Expense #{expenseId} has already been approved.";
|
||||
}
|
||||
|
||||
if (expense.Amount > ApprovalLimit)
|
||||
{
|
||||
return $"Cannot approve expense #{expenseId} (€{expense.Amount:N2}). " +
|
||||
$"Amount exceeds the €{ApprovalLimit:N2} approval limit.";
|
||||
}
|
||||
|
||||
expense.Status = "Approved";
|
||||
expense.ApprovedBy = this._userContext.DisplayName;
|
||||
|
||||
return $"Expense #{expenseId} (\"{expense.Description}\", €{expense.Amount:N2}) has been approved.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to authorize AI agent tools using OAuth 2.0
|
||||
// scopes. The /chat endpoint requires the "agent.chat" scope, and each tool
|
||||
// checks its own scope (expenses.view, expenses.approve) at runtime.
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json.Serialization;
|
||||
using AspNetAgentAuthorization.Service;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication: JWT Bearer tokens validated against the OIDC provider
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = builder.Configuration["Auth:Authority"]
|
||||
?? throw new InvalidOperationException("Auth:Authority is not configured.");
|
||||
options.Audience = builder.Configuration["Auth:Audience"]
|
||||
?? throw new InvalidOperationException("Auth:Audience is not configured.");
|
||||
|
||||
// For local development with HTTP-only Keycloak
|
||||
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
|
||||
|
||||
options.TokenValidationParameters.ValidateAudience = true;
|
||||
options.TokenValidationParameters.ValidateLifetime = true;
|
||||
|
||||
// In Codespaces, tokens are issued with the public tunnel URL as
|
||||
// issuer (Keycloak sees X-Forwarded-Host from the tunnel) but the
|
||||
// agent-service discovers Keycloak via the internal Docker hostname.
|
||||
// Disable issuer validation in development to handle this mismatch.
|
||||
options.TokenValidationParameters.ValidateIssuer = !builder.Environment.IsDevelopment();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorization: policy requiring the "agent.chat" scope
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("AgentChat", policy =>
|
||||
policy.RequireAuthenticatedUser()
|
||||
.RequireAssertion(context =>
|
||||
{
|
||||
// Keycloak puts scopes in the "scope" claim (space-delimited)
|
||||
var scopeClaim = context.User.FindFirstValue("scope");
|
||||
if (scopeClaim is not null)
|
||||
{
|
||||
var scopes = scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (scopes.Contains("agent.chat", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configure JSON serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(SampleServiceSerializerContext.Default));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create the AI agent with expense approval tools, registered in DI
|
||||
// ---------------------------------------------------------------------------
|
||||
string apiKey = builder.Configuration["OPENAI_API_KEY"]
|
||||
?? throw new InvalidOperationException("Set the OPENAI_API_KEY environment variable.");
|
||||
string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini";
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<IUserContext, KeycloakUserContext>();
|
||||
builder.Services.AddScoped<ExpenseService>();
|
||||
builder.Services.AddScoped<AIAgent>(sp =>
|
||||
{
|
||||
var expenseService = sp.GetRequiredService<ExpenseService>();
|
||||
|
||||
return new OpenAIClient(apiKey)
|
||||
.GetChatClient(model)
|
||||
.AsIChatClient()
|
||||
.AsAIAgent(
|
||||
name: "ExpenseApprovalAgent",
|
||||
instructions: "You are an expense approval assistant. You can list pending expenses "
|
||||
+ "and approve them if the user has the required permissions and approval limit. "
|
||||
+ "Keep responses concise.",
|
||||
tools:
|
||||
[
|
||||
AIFunctionFactory.Create(expenseService.ListPendingExpenses),
|
||||
AIFunctionFactory.Create(expenseService.ApproveExpense),
|
||||
]);
|
||||
});
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /chat — requires the "agent.chat" scope
|
||||
// ---------------------------------------------------------------------------
|
||||
app.MapPost("/chat", [Authorize(Policy = "AgentChat")] async (ChatRequest request, IUserContext userContext, AIAgent agent) =>
|
||||
{
|
||||
var response = await agent.RunAsync(request.Message);
|
||||
|
||||
return Results.Ok(new ChatResponse(response.Text, userContext.DisplayName));
|
||||
});
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / Response models
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed record ChatRequest(string Message);
|
||||
internal sealed record ChatResponse(string Reply, string User);
|
||||
|
||||
[JsonSerializable(typeof(ChatRequest))]
|
||||
[JsonSerializable(typeof(ChatResponse))]
|
||||
internal sealed partial class SampleServiceSerializerContext : JsonSerializerContext;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Service": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:55001;http://localhost:5001"
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-13
@@ -1,28 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001</NoWarn>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Copy skills directory to output -->
|
||||
<ItemGroup>
|
||||
<None Include="skills\**\*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace AspNetAgentAuthorization.Service;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the authenticated user's identity for the current request.
|
||||
/// </summary>
|
||||
public interface IUserContext
|
||||
{
|
||||
/// <summary>Unique identifier for the current user (e.g. the OIDC "sub" claim).</summary>
|
||||
string UserId { get; }
|
||||
|
||||
/// <summary>Login name for the current user.</summary>
|
||||
string UserName { get; }
|
||||
|
||||
/// <summary>Human-readable display name (e.g. "Test User").</summary>
|
||||
string DisplayName { get; }
|
||||
|
||||
/// <summary>OAuth scopes granted in the current access token.</summary>
|
||||
IReadOnlySet<string> Scopes { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the current user's identity from Keycloak-specific JWT claims.
|
||||
/// Keycloak uses <c>sub</c> for the user ID, <c>preferred_username</c>
|
||||
/// for the login name, <c>given_name</c>/<c>family_name</c> for the
|
||||
/// display name, and <c>scope</c> (space-delimited) for granted scopes.
|
||||
/// Registered as a scoped service so it is resolved once per request.
|
||||
/// </summary>
|
||||
public sealed class KeycloakUserContext : IUserContext
|
||||
{
|
||||
public string UserId { get; }
|
||||
|
||||
public string UserName { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public IReadOnlySet<string> Scopes { get; }
|
||||
|
||||
public KeycloakUserContext(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
|
||||
|
||||
this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user?.FindFirstValue("sub")
|
||||
?? "anonymous";
|
||||
|
||||
this.UserName = user?.FindFirstValue("preferred_username")
|
||||
?? user?.FindFirstValue(ClaimTypes.Name)
|
||||
?? "unknown";
|
||||
|
||||
string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName);
|
||||
string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname);
|
||||
this.DisplayName = (givenName, familyName) switch
|
||||
{
|
||||
(not null, not null) => $"{givenName} {familyName}",
|
||||
(not null, null) => givenName,
|
||||
(null, not null) => familyName,
|
||||
_ => this.UserName,
|
||||
};
|
||||
|
||||
string? scopeClaim = user?.FindFirstValue("scope");
|
||||
this.Scopes = scopeClaim is not null
|
||||
? new HashSet<string>(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Auth": {
|
||||
"Authority": "http://localhost:5002/realms/dev",
|
||||
"Audience": "agent-service"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
services:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
container_name: auth-keycloak
|
||||
environment:
|
||||
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
||||
- KC_HOSTNAME_STRICT=false
|
||||
- KC_PROXY_HEADERS=xforwarded
|
||||
volumes:
|
||||
- ./keycloak/dev-realm.json:/opt/keycloak/data/import/dev-realm.json
|
||||
command: ["start-dev", "--import-realm"]
|
||||
ports:
|
||||
- "5002:8080"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/master HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
|
||||
# One-shot init container that registers the Codespaces redirect URI
|
||||
# with Keycloak after it becomes healthy. Auto-detects Codespaces via
|
||||
# CODESPACE_NAME and GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN env vars.
|
||||
keycloak-init:
|
||||
image: curlimages/curl:latest
|
||||
container_name: auth-keycloak-init
|
||||
environment:
|
||||
- KEYCLOAK_URL=http://keycloak:8080
|
||||
- CODESPACE_NAME=${CODESPACE_NAME:-}
|
||||
- GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-}
|
||||
volumes:
|
||||
- ./keycloak/setup-redirect-uris.sh:/setup-redirect-uris.sh:ro
|
||||
entrypoint: ["sh", "/setup-redirect-uris.sh"]
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
|
||||
agent-service:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: samples/05-end-to-end/AspNetAgentAuthorization/Service/Dockerfile
|
||||
container_name: auth-agent-service
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- Auth__Authority=http://keycloak:8080/realms/dev
|
||||
- Auth__Audience=agent-service
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4.1-mini}
|
||||
ports:
|
||||
- "5001:5001"
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
|
||||
web-client:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile
|
||||
container_name: auth-web-client
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- Auth__Authority=http://keycloak:8080/realms/dev
|
||||
- Auth__PublicKeycloakUrl=http://localhost:5002
|
||||
- Auth__ClientId=web-client
|
||||
- AgentService__BaseUrl=http://agent-service:5001
|
||||
- CODESPACE_NAME=${CODESPACE_NAME:-}
|
||||
- GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- web-client-keys:/app/keys
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
agent-service:
|
||||
condition: service_started
|
||||
|
||||
volumes:
|
||||
web-client-keys:
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"realm": "dev",
|
||||
"enabled": true,
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": false,
|
||||
"roles": {
|
||||
"realm": [
|
||||
{
|
||||
"name": "agent-chat-user",
|
||||
"description": "Grants access to the agent.chat scope"
|
||||
},
|
||||
{
|
||||
"name": "expenses-viewer",
|
||||
"description": "Grants access to the expenses.view scope"
|
||||
},
|
||||
{
|
||||
"name": "expenses-approver",
|
||||
"description": "Grants access to the expenses.approve scope"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scopeMappings": [
|
||||
{
|
||||
"clientScope": "agent.chat",
|
||||
"roles": ["agent-chat-user"]
|
||||
},
|
||||
{
|
||||
"clientScope": "expenses.view",
|
||||
"roles": ["expenses-viewer"]
|
||||
},
|
||||
{
|
||||
"clientScope": "expenses.approve",
|
||||
"roles": ["expenses-approver"]
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
{
|
||||
"name": "openid",
|
||||
"description": "OpenID Connect scope",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "sub",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-sub-mapper",
|
||||
"config": {
|
||||
"introspection.token.claim": "true",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "profile",
|
||||
"description": "OpenID Connect profile scope",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "preferred_username",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"config": {
|
||||
"user.attribute": "username",
|
||||
"claim.name": "preferred_username",
|
||||
"jsonType.label": "String",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "given_name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"config": {
|
||||
"user.attribute": "firstName",
|
||||
"claim.name": "given_name",
|
||||
"jsonType.label": "String",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "family_name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"config": {
|
||||
"user.attribute": "lastName",
|
||||
"claim.name": "family_name",
|
||||
"jsonType.label": "String",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"description": "OpenID Connect email scope",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "agent.chat",
|
||||
"description": "Allows chatting with the agent",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expenses.view",
|
||||
"description": "Allows viewing pending expenses",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expenses.approve",
|
||||
"description": "Allows approving pending expenses",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "agent-service-audience",
|
||||
"description": "Adds the agent-service audience to access tokens",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "false",
|
||||
"display.on.consent.screen": "false"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "agent-service-audience-mapper",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"config": {
|
||||
"included.client.audience": "agent-service",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "agent-service",
|
||||
"enabled": true,
|
||||
"publicClient": false,
|
||||
"secret": "agent-service-secret",
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": false,
|
||||
"standardFlowEnabled": false,
|
||||
"protocol": "openid-connect"
|
||||
},
|
||||
{
|
||||
"clientId": "web-client",
|
||||
"enabled": true,
|
||||
"publicClient": true,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"standardFlowEnabled": true,
|
||||
"fullScopeAllowed": false,
|
||||
"protocol": "openid-connect",
|
||||
"redirectUris": [
|
||||
"http://localhost:8080/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:8080"
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"agent-service-audience"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"agent.chat",
|
||||
"expenses.view",
|
||||
"expenses.approve"
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "testuser",
|
||||
"enabled": true,
|
||||
"email": "testuser@example.com",
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"realmRoles": ["agent-chat-user", "expenses-viewer", "expenses-approver"],
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password",
|
||||
"temporary": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "viewer",
|
||||
"enabled": true,
|
||||
"email": "viewer@example.com",
|
||||
"firstName": "View",
|
||||
"lastName": "Only",
|
||||
"realmRoles": ["agent-chat-user", "expenses-viewer"],
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password",
|
||||
"temporary": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# Adds an extra redirect URI to the Keycloak web-client configuration.
|
||||
# Auto-detects GitHub Codespaces via CODESPACE_NAME and
|
||||
# GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN environment variables.
|
||||
|
||||
set -e
|
||||
|
||||
KEYCLOAK_URL="${KEYCLOAK_URL:-http://keycloak:8080}"
|
||||
|
||||
# Auto-detect Codespaces
|
||||
if [ -n "$CODESPACE_NAME" ] && [ -n "$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" ]; then
|
||||
WEBCLIENT_PUBLIC_URL="https://${CODESPACE_NAME}-8080.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}"
|
||||
fi
|
||||
|
||||
if [ -z "$WEBCLIENT_PUBLIC_URL" ]; then
|
||||
echo "Not running in Codespaces — skipping redirect URI setup."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Configuring Keycloak redirect URIs for: $WEBCLIENT_PUBLIC_URL"
|
||||
|
||||
# Get admin token
|
||||
TOKEN=$(curl -sf -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \
|
||||
-d "grant_type=password&client_id=admin-cli&username=admin&password=admin" \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "ERROR: Failed to get admin token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get web-client UUID
|
||||
CLIENT_UUID=$(curl -sf "$KEYCLOAK_URL/admin/realms/dev/clients?clientId=web-client" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
|
||||
|
||||
if [ -z "$CLIENT_UUID" ]; then
|
||||
echo "ERROR: Failed to find web-client UUID" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Update redirect URIs and web origins
|
||||
curl -sf -X PUT "$KEYCLOAK_URL/admin/realms/dev/clients/$CLIENT_UUID" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"redirectUris\": [\"http://localhost:8080/*\", \"${WEBCLIENT_PUBLIC_URL}/*\"],
|
||||
\"webOrigins\": [\"http://localhost:8080\", \"${WEBCLIENT_PUBLIC_URL}\"]
|
||||
}"
|
||||
|
||||
echo "Keycloak redirect URIs updated successfully."
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
|
||||
<!--
|
||||
Disable central package management for this project.
|
||||
This project requires explicit package references with versions specified inline rather than
|
||||
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
|
||||
be created from this project, and the Docker build process only has access to this folder
|
||||
and cannot access parent folders where Directory.Packages.props resides.
|
||||
-->
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Remove analyzer PackageReference items inherited from Directory.Packages.props.
|
||||
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
|
||||
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
|
||||
and the PackageReference items are always inherited through MSBuild imports regardless of the
|
||||
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
|
||||
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
|
||||
<PackageReference Remove="xunit.analyzers" />
|
||||
<PackageReference Remove="Moq.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
# Build the application
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy files from the current directory on the host to the working directory in the container
|
||||
COPY . .
|
||||
|
||||
RUN dotnet restore
|
||||
RUN dotnet build -c Release --no-restore
|
||||
RUN dotnet publish -c Release --no-build -o /app -f net10.0
|
||||
|
||||
# Run the application
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything needed to run the app from the "build" stage.
|
||||
COPY --from=build /app .
|
||||
|
||||
EXPOSE 8088
|
||||
ENTRYPOINT ["dotnet", "AgentThreadAndHITL.dll"]
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates Human-in-the-Loop (HITL) capabilities with thread persistence.
|
||||
// The agent wraps function tools with ApprovalRequiredAIFunction to require user approval
|
||||
// before invoking them. Users respond with 'approve' or 'reject' when prompted.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.AgentServer.AgentFramework.Extensions;
|
||||
using Azure.AI.AgentServer.AgentFramework.Persistence;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
[Description("Get the weather for a given location.")]
|
||||
static string GetWeather([Description("The location to get the weather for.")] string location)
|
||||
=> $"The weather in {location} is cloudy with a high of 15°C.";
|
||||
|
||||
// Create the chat client and agent.
|
||||
// Note: ApprovalRequiredAIFunction wraps the tool to require user approval before invocation.
|
||||
// User should reply with 'approve' or 'reject' when prompted.
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.CreateAIAgent(
|
||||
instructions: "You are a helpful assistant",
|
||||
tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]
|
||||
);
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
var threadRepository = new InMemoryAgentThreadRepository(agent);
|
||||
await agent.RunAIAgentAsync(telemetrySourceName: "Agents", threadRepository: threadRepository);
|
||||
@@ -0,0 +1,46 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates Human-in-the-Loop (HITL) capabilities with thread persistence. The agent wraps function tools with `ApprovalRequiredAIFunction` so that every tool invocation requires explicit user approval before execution. Thread state is maintained across requests using `InMemoryAgentThreadRepository`.
|
||||
|
||||
Key features:
|
||||
- Requiring human approval before executing function calls
|
||||
- Persisting conversation threads across multiple requests
|
||||
- Approving or rejecting tool invocations at runtime
|
||||
|
||||
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. .NET 10 SDK installed
|
||||
2. An Azure OpenAI endpoint configured
|
||||
3. A deployment of a chat model (e.g., gpt-4o-mini)
|
||||
4. Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
# Replace with your Azure OpenAI endpoint
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/"
|
||||
|
||||
# Optional, defaults to gpt-4o-mini
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The sample uses `ApprovalRequiredAIFunction` to wrap standard AI function tools. When the model decides to call a tool, the wrapper intercepts the invocation and returns a HITL approval request to the caller instead of executing the function immediately.
|
||||
|
||||
1. The user sends a message (e.g., "What is the weather in Vancouver?")
|
||||
2. The model determines a function call is needed and selects the `GetWeather` tool
|
||||
3. `ApprovalRequiredAIFunction` intercepts the call and returns an approval request containing the function name and arguments
|
||||
4. The user responds with `approve` or `reject`
|
||||
5. If approved, the function executes and the model generates a response using the result
|
||||
6. If rejected, the model generates a response without the function result
|
||||
|
||||
Thread persistence is handled by `InMemoryAgentThreadRepository`, which stores conversation history keyed by `conversation.id`. This means the HITL flow works across multiple HTTP requests as long as each request includes the same `conversation.id`.
|
||||
|
||||
> **Note:** HITL requires a stable `conversation.id` in every request so the agent can correlate the approval response with the original function call. Use the `run-requests.http` file in this directory to test the full approval flow.
|
||||
@@ -0,0 +1,28 @@
|
||||
name: AgentThreadAndHITL
|
||||
displayName: "Weather Assistant Agent"
|
||||
description: >
|
||||
A Weather Assistant Agent that provides weather information and forecasts. It
|
||||
demonstrates how to use Azure AI AgentServer with Human-in-the-Loop (HITL)
|
||||
capabilities to get human approval for functional calls.
|
||||
metadata:
|
||||
authors:
|
||||
- Microsoft Agent Framework Team
|
||||
tags:
|
||||
- Azure AI AgentServer
|
||||
- Microsoft Agent Framework
|
||||
- Human-in-the-Loop
|
||||
template:
|
||||
kind: hosted
|
||||
name: AgentThreadAndHITL
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: v1
|
||||
environment_variables:
|
||||
- name: AZURE_OPENAI_ENDPOINT
|
||||
value: ${AZURE_OPENAI_ENDPOINT}
|
||||
- name: AZURE_OPENAI_DEPLOYMENT_NAME
|
||||
value: gpt-4o-mini
|
||||
resources:
|
||||
- name: "gpt-4o-mini"
|
||||
kind: model
|
||||
id: gpt-4o-mini
|
||||
@@ -0,0 +1,70 @@
|
||||
@host = http://localhost:8088
|
||||
@endpoint = {{host}}/responses
|
||||
|
||||
### Health Check
|
||||
GET {{host}}/readiness
|
||||
|
||||
###
|
||||
# HITL (Human-in-the-Loop) Flow
|
||||
#
|
||||
# This sample requires a multi-turn conversation to demonstrate the approval flow:
|
||||
# 1. Send a request that triggers a tool call (e.g., asking about the weather)
|
||||
# 2. The agent responds with a function_call named "__hosted_agent_adapter_hitl__"
|
||||
# containing the call_id and the tool details
|
||||
# 3. Send a follow-up request with a function_call_output to approve or reject
|
||||
#
|
||||
# IMPORTANT: You must use the same conversation.id across all requests in a flow,
|
||||
# and update the call_id from step 2 into step 3.
|
||||
###
|
||||
|
||||
### Step 1: Send initial request (triggers HITL approval)
|
||||
# @name initialRequest
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": "What is the weather like in Vancouver?",
|
||||
"stream": false,
|
||||
"conversation": {
|
||||
"id": "conv_test0000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
|
||||
### Step 2: Approve the function call
|
||||
# Copy the call_id from the Step 1 response output and replace below.
|
||||
# The response will contain: "name": "__hosted_agent_adapter_hitl__" with a "call_id" value.
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "REPLACE_WITH_CALL_ID_FROM_STEP_1",
|
||||
"output": "approve"
|
||||
}
|
||||
],
|
||||
"stream": false,
|
||||
"conversation": {
|
||||
"id": "conv_test0000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
|
||||
### Step 3 (alternative): Reject the function call
|
||||
# Use this instead of Step 2 to deny the tool execution.
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "REPLACE_WITH_CALL_ID_FROM_STEP_1",
|
||||
"output": "reject"
|
||||
}
|
||||
],
|
||||
"stream": false,
|
||||
"conversation": {
|
||||
"id": "conv_test0000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ Key features:
|
||||
- Filtering available tools from an MCP server
|
||||
- Using Azure OpenAI Responses with MCP tools
|
||||
|
||||
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnablePreviewFeatures>true</EnablePreviewFeatures>
|
||||
|
||||
<!--
|
||||
Disable central package management for this project.
|
||||
This project requires explicit package references with versions specified inline rather than
|
||||
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
|
||||
be created from this project, and the Docker build process only has access to this folder
|
||||
and cannot access parent folders where Directory.Packages.props resides.
|
||||
-->
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Remove analyzer PackageReference items inherited from Directory.Packages.props.
|
||||
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
|
||||
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
|
||||
and the PackageReference items are always inherited through MSBuild imports regardless of the
|
||||
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
|
||||
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
|
||||
<PackageReference Remove="xunit.analyzers" />
|
||||
<PackageReference Remove="Moq.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
|
||||
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
# Build the application
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy files from the current directory on the host to the working directory in the container
|
||||
COPY . .
|
||||
|
||||
RUN dotnet restore
|
||||
RUN dotnet build -c Release --no-restore
|
||||
RUN dotnet publish -c Release --no-build -o /app -f net10.0
|
||||
|
||||
# Run the application
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything needed to run the app from the "build" stage.
|
||||
COPY --from=build /app .
|
||||
|
||||
EXPOSE 8088
|
||||
ENTRYPOINT ["dotnet", "AgentWithLocalTools.dll"]
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle.
|
||||
// Uses Microsoft Agent Framework with Azure AI Foundry.
|
||||
// Ready for deployment to Foundry Hosted Agent service.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Azure.AI.AgentServer.AgentFramework.Extensions;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
Console.WriteLine($"Project Endpoint: {endpoint}");
|
||||
Console.WriteLine($"Model Deployment: {deploymentName}");
|
||||
|
||||
var seattleHotels = new[]
|
||||
{
|
||||
new Hotel("Contoso Suites", 189, 4.5, "Downtown"),
|
||||
new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"),
|
||||
new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"),
|
||||
new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"),
|
||||
new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"),
|
||||
new Hotel("Relecloud Hotel", 99, 3.8, "University District"),
|
||||
};
|
||||
|
||||
[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")]
|
||||
string GetAvailableHotels(
|
||||
[Description("Check-in date in YYYY-MM-DD format")] string checkInDate,
|
||||
[Description("Check-out date in YYYY-MM-DD format")] string checkOutDate,
|
||||
[Description("Maximum price per night in USD (optional, defaults to 500)")] int maxPrice = 500)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!DateTime.TryParseExact(checkInDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkIn))
|
||||
{
|
||||
return "Error parsing check-in date. Please use YYYY-MM-DD format.";
|
||||
}
|
||||
|
||||
if (!DateTime.TryParseExact(checkOutDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkOut))
|
||||
{
|
||||
return "Error parsing check-out date. Please use YYYY-MM-DD format.";
|
||||
}
|
||||
|
||||
if (checkOut <= checkIn)
|
||||
{
|
||||
return "Error: Check-out date must be after check-in date.";
|
||||
}
|
||||
|
||||
var nights = (checkOut - checkIn).Days;
|
||||
var availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
|
||||
|
||||
if (availableHotels.Count == 0)
|
||||
{
|
||||
return $"No hotels found in Seattle within your budget of ${maxPrice}/night.";
|
||||
}
|
||||
|
||||
var result = new StringBuilder();
|
||||
result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):");
|
||||
result.AppendLine();
|
||||
|
||||
foreach (var hotel in availableHotels)
|
||||
{
|
||||
var totalCost = hotel.PricePerNight * nights;
|
||||
result.AppendLine($"**{hotel.Name}**");
|
||||
result.AppendLine($" Location: {hotel.Location}");
|
||||
result.AppendLine($" Rating: {hotel.Rating}/5");
|
||||
result.AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})");
|
||||
result.AppendLine();
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error processing request. Details: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
var credential = new AzureCliCredential();
|
||||
AIProjectClient projectClient = new(new Uri(endpoint), credential);
|
||||
|
||||
ClientConnection connection = projectClient.GetConnection(typeof(AzureOpenAIClient).FullName!);
|
||||
|
||||
if (!connection.TryGetLocatorAsUri(out Uri? openAiEndpoint) || openAiEndpoint is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to get OpenAI endpoint from project connection.");
|
||||
}
|
||||
openAiEndpoint = new Uri($"https://{openAiEndpoint.Host}");
|
||||
Console.WriteLine($"OpenAI Endpoint: {openAiEndpoint}");
|
||||
|
||||
var chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false)
|
||||
.Build();
|
||||
|
||||
var agent = new ChatClientAgent(chatClient,
|
||||
name: "SeattleHotelAgent",
|
||||
instructions: """
|
||||
You are a helpful travel assistant specializing in finding hotels in Seattle, Washington.
|
||||
|
||||
When a user asks about hotels in Seattle:
|
||||
1. Ask for their check-in and check-out dates if not provided
|
||||
2. Ask about their budget preferences if not mentioned
|
||||
3. Use the GetAvailableHotels tool to find available options
|
||||
4. Present the results in a friendly, informative way
|
||||
5. Offer to help with additional questions about the hotels or Seattle
|
||||
|
||||
Be conversational and helpful. If users ask about things outside of Seattle hotels,
|
||||
politely let them know you specialize in Seattle hotel recommendations.
|
||||
""",
|
||||
tools: [AIFunctionFactory.Create(GetAvailableHotels)])
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false)
|
||||
.Build();
|
||||
|
||||
Console.WriteLine("Seattle Hotel Agent Server running on http://localhost:8088");
|
||||
await agent.RunAIAgentAsync(telemetrySourceName: "Agents");
|
||||
|
||||
internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location);
|
||||
@@ -0,0 +1,39 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to build a hosted agent that uses local C# function tools — a key advantage of code-based hosted agents over prompt agents. The agent acts as a Seattle travel assistant with a `GetAvailableHotels` tool that simulates querying a hotel availability API.
|
||||
|
||||
Key features:
|
||||
- Defining local C# functions as agent tools using `AIFunctionFactory`
|
||||
- Using `AIProjectClient` to discover the OpenAI connection from the Azure AI Foundry project
|
||||
- Building a `ChatClientAgent` with custom instructions and tools
|
||||
- Deploying to the Foundry Hosted Agent service
|
||||
|
||||
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. .NET 10 SDK installed
|
||||
2. An Azure AI Foundry Project with a chat model deployed (e.g., gpt-4o-mini)
|
||||
3. Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
# Replace with your Azure AI Foundry project endpoint
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name"
|
||||
|
||||
# Optional, defaults to gpt-4o-mini
|
||||
$env:MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The agent uses `AIProjectClient` to discover the Azure OpenAI connection from the project endpoint
|
||||
2. A local C# function `GetAvailableHotels` is registered as a tool using `AIFunctionFactory.Create`
|
||||
3. When users ask about hotels, the model invokes the local tool to search simulated hotel data
|
||||
4. The tool filters hotels by price and calculates total costs based on the requested dates
|
||||
5. Results are returned to the model, which presents them in a conversational format
|
||||
@@ -0,0 +1,29 @@
|
||||
name: seattle-hotel-agent
|
||||
description: >
|
||||
A travel assistant agent that helps users find hotels in Seattle.
|
||||
Demonstrates local C# tool execution - a key advantage of code-based
|
||||
hosted agents over prompt agents.
|
||||
metadata:
|
||||
authors:
|
||||
- Microsoft
|
||||
tags:
|
||||
- Azure AI AgentServer
|
||||
- Microsoft Agent Framework
|
||||
- Local Tools
|
||||
- Travel Assistant
|
||||
- Hotel Search
|
||||
template:
|
||||
name: seattle-hotel-agent
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: v1
|
||||
environment_variables:
|
||||
- name: AZURE_AI_PROJECT_ENDPOINT
|
||||
value: ${AZURE_AI_PROJECT_ENDPOINT}
|
||||
- name: MODEL_DEPLOYMENT_NAME
|
||||
value: gpt-4o-mini
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4o-mini
|
||||
name: chat
|
||||
@@ -0,0 +1,52 @@
|
||||
@host = http://localhost:8088
|
||||
@endpoint = {{host}}/responses
|
||||
|
||||
### Health Check
|
||||
GET {{host}}/readiness
|
||||
|
||||
### Simple hotel search - budget under $200
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under $200 per night",
|
||||
"stream": false
|
||||
}
|
||||
|
||||
### Hotel search with higher budget
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": "Find me hotels in Seattle for March 20-23, 2025 under $250 per night",
|
||||
"stream": false
|
||||
}
|
||||
|
||||
### Ask for recommendations without dates (agent should ask for clarification)
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": "What hotels do you recommend in Seattle?",
|
||||
"stream": false
|
||||
}
|
||||
|
||||
### Explicit input format
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": "I'm looking for a hotel in Seattle from 2025-04-01 to 2025-04-05, my budget is $150 per night maximum"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stream": false
|
||||
}
|
||||
@@ -8,6 +8,8 @@ Key features:
|
||||
- Managing conversation memory with a rolling window approach
|
||||
- Citing source documents in AI responses
|
||||
|
||||
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<!--
|
||||
Disable central package management for this project.
|
||||
This project requires explicit package references with versions specified inline rather than
|
||||
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
|
||||
be created from this project, and the Docker build process only has access to this folder
|
||||
and cannot access parent folders where Directory.Packages.props resides.
|
||||
-->
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Remove analyzer PackageReference items inherited from Directory.Packages.props.
|
||||
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
|
||||
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
|
||||
and the PackageReference items are always inherited through MSBuild imports regardless of the
|
||||
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
|
||||
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
|
||||
<PackageReference Remove="xunit.analyzers" />
|
||||
<PackageReference Remove="Moq.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
# Build the application
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy files from the current directory on the host to the working directory in the container
|
||||
COPY . .
|
||||
|
||||
RUN dotnet restore
|
||||
RUN dotnet build -c Release --no-restore
|
||||
RUN dotnet publish -c Release --no-build -o /app -f net10.0
|
||||
|
||||
# Run the application
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything needed to run the app from the "build" stage.
|
||||
COPY --from=build /app .
|
||||
|
||||
EXPOSE 8088
|
||||
ENTRYPOINT ["dotnet", "AgentWithTools.dll"]
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use Foundry tools (MCP and code interpreter)
|
||||
// with an AI agent hosted using the Azure AI AgentServer SDK.
|
||||
|
||||
using Azure.AI.AgentServer.AgentFramework.Extensions;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
|
||||
|
||||
var credential = new AzureCliCredential();
|
||||
|
||||
var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.UseFoundryTools(new { type = "mcp", project_connection_id = toolConnectionId }, new { type = "code_interpreter" })
|
||||
.UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true)
|
||||
.Build();
|
||||
|
||||
var agent = new ChatClientAgent(chatClient,
|
||||
name: "AgentWithTools",
|
||||
instructions: @"You are a helpful assistant with access to tools for fetching Microsoft documentation.
|
||||
|
||||
IMPORTANT: When the user asks about Microsoft Learn articles or documentation:
|
||||
1. You MUST use the microsoft_docs_fetch tool to retrieve the actual content
|
||||
2. Do NOT rely on your training data
|
||||
3. Always fetch the latest information from the provided URL
|
||||
|
||||
Available tools:
|
||||
- microsoft_docs_fetch: Fetches and converts Microsoft Learn documentation
|
||||
- microsoft_docs_search: Searches Microsoft/Azure documentation
|
||||
- microsoft_code_sample_search: Searches for code examples")
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true)
|
||||
.Build();
|
||||
|
||||
await agent.RunAIAgentAsync(telemetrySourceName: "Agents");
|
||||
@@ -0,0 +1,45 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use Foundry tools with an AI agent via the `UseFoundryTools` extension. The agent is configured with two tool types: an MCP (Model Context Protocol) connection for fetching Microsoft Learn documentation and a code interpreter for running code when needed.
|
||||
|
||||
Key features:
|
||||
|
||||
- Configuring Foundry tools using `UseFoundryTools` with MCP and code interpreter
|
||||
- Connecting to an external MCP tool via a Foundry project connection
|
||||
- Using `AzureCliCredential` for Azure authentication
|
||||
- OpenTelemetry instrumentation for both the chat client and agent
|
||||
|
||||
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
In addition to the common prerequisites:
|
||||
|
||||
1. An **Azure AI Foundry project** with a chat model deployed (e.g., `gpt-5.2`, `gpt-4o-mini`)
|
||||
2. The **Azure AI Developer** role assigned on the Foundry resource (includes the `agents/write` data action required by `UseFoundryTools`)
|
||||
3. An **MCP tool connection** configured in your Foundry project pointing to `https://learn.microsoft.com/api/mcp`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
In addition to the common environment variables in the root README:
|
||||
|
||||
```powershell
|
||||
# Your Azure AI Foundry project endpoint (required by UseFoundryTools)
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-resource.services.ai.azure.com/api/projects/your-project"
|
||||
|
||||
# Chat model deployment name (defaults to gpt-4o-mini if not set)
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
|
||||
|
||||
# The MCP tool connection name (just the name, not the full ARM resource ID)
|
||||
$env:MCP_TOOL_CONNECTION_ID="SampleMCPTool"
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. An `AzureOpenAIClient` is created with `AzureCliCredential` and used to get a chat client
|
||||
2. The chat client is wrapped with `UseFoundryTools` which registers two Foundry tool types:
|
||||
- **MCP connection**: Connects to an external MCP server (Microsoft Learn) via the project connection name, providing documentation fetch and search capabilities
|
||||
- **Code interpreter**: Allows the agent to execute code snippets when needed
|
||||
3. `UseFoundryTools` resolves the connection using `AZURE_AI_PROJECT_ENDPOINT` internally
|
||||
4. A `ChatClientAgent` is created with instructions guiding it to use the MCP tools for documentation queries
|
||||
5. The agent is hosted using `RunAIAgentAsync` which exposes the OpenAI Responses-compatible API endpoint
|
||||
@@ -0,0 +1,31 @@
|
||||
name: AgentWithTools
|
||||
displayName: "Agent with Tools"
|
||||
description: >
|
||||
An AI agent that uses Foundry tools (MCP and code interpreter) with Azure OpenAI.
|
||||
The agent can fetch Microsoft Learn documentation and run code when needed.
|
||||
metadata:
|
||||
authors:
|
||||
- Microsoft Agent Framework Team
|
||||
tags:
|
||||
- Azure AI AgentServer
|
||||
- Microsoft Agent Framework
|
||||
- Tools
|
||||
- MCP
|
||||
- Code Interpreter
|
||||
template:
|
||||
kind: hosted
|
||||
name: AgentWithTools
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: v1
|
||||
environment_variables:
|
||||
- name: AZURE_OPENAI_ENDPOINT
|
||||
value: ${AZURE_OPENAI_ENDPOINT}
|
||||
- name: AZURE_OPENAI_DEPLOYMENT_NAME
|
||||
value: gpt-4o-mini
|
||||
- name: MCP_TOOL_CONNECTION_ID
|
||||
value: ${MCP_TOOL_CONNECTION_ID}
|
||||
resources:
|
||||
- name: "gpt-4o-mini"
|
||||
kind: model
|
||||
id: gpt-4o-mini
|
||||
@@ -0,0 +1,30 @@
|
||||
@host = http://localhost:8088
|
||||
@endpoint = {{host}}/responses
|
||||
|
||||
### Health Check
|
||||
GET {{host}}/readiness
|
||||
|
||||
### Simple string input
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"input": "Please use the microsoft_docs_fetch tool to fetch and summarize the Microsoft Learn article at https://learn.microsoft.com/azure/ai-services/openai/overview"
|
||||
}
|
||||
|
||||
### Explicit input
|
||||
POST {{endpoint}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": "Please use the microsoft_docs_fetch tool to fetch and summarize the Microsoft Learn article at https://learn.microsoft.com/azure/ai-services/openai/overview"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,8 @@ This workflow uses three translation agents:
|
||||
|
||||
The agents are connected sequentially, creating a translation chain that demonstrates how AI-powered components can be seamlessly integrated into workflow pipelines.
|
||||
|
||||
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Hosted Agent Samples
|
||||
|
||||
These samples demonstrate how to build and host AI agents using the [Azure AI AgentServer SDK](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/ai.agentserver.agentframework-readme). Each sample can be run locally and deployed to Microsoft Foundry as a hosted agent.
|
||||
|
||||
## Samples
|
||||
|
||||
| Sample | Description |
|
||||
|--------|-------------|
|
||||
| [`AgentWithTools`](./AgentWithTools/) | Foundry tools (MCP + code interpreter) via `UseFoundryTools` |
|
||||
| [`AgentWithLocalTools`](./AgentWithLocalTools/) | Local C# function tool execution (Seattle hotel search) |
|
||||
| [`AgentThreadAndHITL`](./AgentThreadAndHITL/) | Human-in-the-loop with `ApprovalRequiredAIFunction` and thread persistence |
|
||||
| [`AgentWithHostedMCP`](./AgentWithHostedMCP/) | Hosted MCP server tool (Microsoft Learn search) |
|
||||
| [`AgentWithTextSearchRag`](./AgentWithTextSearchRag/) | RAG with `TextSearchProvider` (Contoso Outdoors) |
|
||||
| [`AgentsInWorkflows`](./AgentsInWorkflows/) | Sequential workflow pipeline (translation chain) |
|
||||
|
||||
## Common Prerequisites
|
||||
|
||||
Before running any sample, ensure you have:
|
||||
|
||||
1. **.NET 10 SDK** or later — [Download](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
2. **Azure CLI** installed — [Install guide](https://learn.microsoft.com/cli/azure/install-azure-cli)
|
||||
3. **Azure OpenAI** or **Azure AI Foundry project** with a chat model deployed (e.g., `gpt-4o-mini`)
|
||||
|
||||
### Authenticate with Azure CLI
|
||||
|
||||
All samples use `AzureCliCredential` for authentication. Make sure you're logged in:
|
||||
|
||||
```powershell
|
||||
az login
|
||||
az account show # Verify the correct subscription
|
||||
```
|
||||
|
||||
### Common Environment Variables
|
||||
|
||||
Most samples require one or more of these environment variables:
|
||||
|
||||
| Variable | Used By | Description |
|
||||
|----------|---------|-------------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Most samples | Your Azure OpenAI resource endpoint URL |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Most samples | Chat model deployment name (defaults to `gpt-4o-mini`) |
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithTools, AgentWithLocalTools | Azure AI Foundry project endpoint |
|
||||
| `MCP_TOOL_CONNECTION_ID` | AgentWithTools | Foundry MCP tool connection name |
|
||||
| `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools | Chat model deployment name (defaults to `gpt-4o-mini`) |
|
||||
|
||||
See each sample's README for the specific variables required.
|
||||
|
||||
## Azure AI Foundry Setup (for samples that use Foundry)
|
||||
|
||||
Some samples (`AgentWithTools`, `AgentWithLocalTools`) connect to an Azure AI Foundry project. If you're using these samples, you'll need additional setup.
|
||||
|
||||
### Azure AI Developer Role
|
||||
|
||||
The `UseFoundryTools` extension requires the **Azure AI Developer** role on the Cognitive Services resource. Even if you created the project, you may not have this role by default.
|
||||
|
||||
```powershell
|
||||
az role assignment create `
|
||||
--role "Azure AI Developer" `
|
||||
--assignee "your-email@microsoft.com" `
|
||||
--scope "/subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.CognitiveServices/accounts/{account-name}"
|
||||
```
|
||||
|
||||
> **Note**: You need **Owner** or **User Access Administrator** permissions on the resource to assign roles. If you don't have this, you may need to request JIT (Just-In-Time) elevated access via [Azure PIM](https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/ActivationMenuBlade/~/aadmigratedresource).
|
||||
|
||||
For more details on permissions, see [Azure AI Foundry Permissions](https://aka.ms/FoundryPermissions).
|
||||
|
||||
### Creating an MCP Tool Connection
|
||||
|
||||
The `AgentWithTools` sample requires an MCP tool connection configured in your Foundry project:
|
||||
|
||||
1. Go to the [Azure AI Foundry portal](https://ai.azure.com)
|
||||
2. Navigate to your project
|
||||
3. Go to **Connected resources** → **+ New connection** → **Model Context Protocol tool**
|
||||
4. Fill in:
|
||||
- **Name**: `SampleMCPTool` (or any name you prefer)
|
||||
- **Remote MCP Server endpoint**: `https://learn.microsoft.com/api/mcp`
|
||||
- **Authentication**: `Unauthenticated`
|
||||
5. Click **Connect**
|
||||
|
||||
The connection **name** (e.g., `SampleMCPTool`) is used as the `MCP_TOOL_CONNECTION_ID` environment variable.
|
||||
|
||||
> **Important**: Use only the connection **name**, not the full ARM resource ID.
|
||||
|
||||
## Running a Sample
|
||||
|
||||
Each sample runs as a standalone hosted agent on `http://localhost:8088/`:
|
||||
|
||||
```powershell
|
||||
cd <sample-directory>
|
||||
dotnet run
|
||||
```
|
||||
|
||||
### Interacting with the Agent
|
||||
|
||||
Each sample includes a `run-requests.http` file for testing with the [VS Code REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) extension, or you can use PowerShell:
|
||||
|
||||
```powershell
|
||||
$body = @{ input = "Your question here" } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "http://localhost:8088/responses" -Method Post -Body $body -ContentType "application/json"
|
||||
```
|
||||
|
||||
## Deploying to Microsoft Foundry
|
||||
|
||||
Each sample includes a `Dockerfile` and `agent.yaml` for deployment. To deploy your agent to Microsoft Foundry, follow the [hosted agents deployment guide](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `PermissionDenied` — lacks `agents/write` data action
|
||||
|
||||
Assign the **Azure AI Developer** role to your user. See [Azure AI Developer Role](#azure-ai-developer-role) above.
|
||||
|
||||
### `Project connection ... was not found`
|
||||
|
||||
Make sure `MCP_TOOL_CONNECTION_ID` contains only the connection **name** (e.g., `SampleMCPTool`), not the full ARM resource ID path.
|
||||
|
||||
### `AZURE_AI_PROJECT_ENDPOINT must be set`
|
||||
|
||||
The `UseFoundryTools` extension requires `AZURE_AI_PROJECT_ENDPOINT`. Set it to your Foundry project endpoint (e.g., `https://your-resource.services.ai.azure.com/api/projects/your-project`).
|
||||
|
||||
### Multi-framework error when running `dotnet run`
|
||||
|
||||
If you see "Your project targets multiple frameworks", specify the framework:
|
||||
|
||||
```powershell
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
@@ -31,6 +31,7 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
|
||||
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
IReadOnlyList<ChatMessage>? conversationHistory = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Create options with properties from the request
|
||||
@@ -51,9 +52,14 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
|
||||
};
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// Convert input to chat messages
|
||||
// Convert input to chat messages, prepending conversation history if available
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
if (conversationHistory is not null)
|
||||
{
|
||||
messages.AddRange(conversationHistory);
|
||||
}
|
||||
|
||||
foreach (var inputMessage in request.Input.GetInputMessages())
|
||||
{
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Converts stored <see cref="ItemResource"/> objects back to <see cref="ChatMessage"/> objects
|
||||
/// for injecting conversation history into agent execution.
|
||||
/// </summary>
|
||||
internal static class ItemResourceConversions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a sequence of <see cref="ItemResource"/> items to a list of <see cref="ChatMessage"/> objects.
|
||||
/// Only converts message, function call, and function result items. Other item types are skipped.
|
||||
/// </summary>
|
||||
public static List<ChatMessage> ToChatMessages(IEnumerable<ItemResource> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case ResponsesUserMessageItemResource userMsg:
|
||||
messages.Add(new ChatMessage(ChatRole.User, ConvertContents(userMsg.Content)));
|
||||
break;
|
||||
|
||||
case ResponsesAssistantMessageItemResource assistantMsg:
|
||||
messages.Add(new ChatMessage(ChatRole.Assistant, ConvertContents(assistantMsg.Content)));
|
||||
break;
|
||||
|
||||
case ResponsesSystemMessageItemResource systemMsg:
|
||||
messages.Add(new ChatMessage(ChatRole.System, ConvertContents(systemMsg.Content)));
|
||||
break;
|
||||
|
||||
case ResponsesDeveloperMessageItemResource developerMsg:
|
||||
messages.Add(new ChatMessage(new ChatRole("developer"), ConvertContents(developerMsg.Content)));
|
||||
break;
|
||||
|
||||
case FunctionToolCallItemResource funcCall:
|
||||
var arguments = ParseArguments(funcCall.Arguments);
|
||||
messages.Add(new ChatMessage(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)
|
||||
]));
|
||||
break;
|
||||
|
||||
case FunctionToolCallOutputItemResource funcOutput:
|
||||
messages.Add(new ChatMessage(ChatRole.Tool,
|
||||
[
|
||||
new FunctionResultContent(funcOutput.CallId, funcOutput.Output)
|
||||
]));
|
||||
break;
|
||||
|
||||
// Skip all other item types (reasoning, executor_action, web_search, etc.)
|
||||
// They are not relevant for conversation context.
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static List<AIContent> ConvertContents(List<ItemContent> contents)
|
||||
{
|
||||
var result = new List<AIContent>();
|
||||
foreach (var content in contents)
|
||||
{
|
||||
var aiContent = ItemContentConverter.ToAIContent(content);
|
||||
if (aiContent is not null)
|
||||
{
|
||||
result.Add(aiContent);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?>? ParseArguments(string? argumentsJson)
|
||||
{
|
||||
if (string.IsNullOrEmpty(argumentsJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(argumentsJson);
|
||||
var result = new Dictionary<string, object?>();
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
result[property.Name] = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number => property.Value.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => property.Value.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
IReadOnlyList<ChatMessage>? conversationHistory = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string agentName = GetAgentName(request)!;
|
||||
@@ -105,6 +106,11 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
if (conversationHistory is not null)
|
||||
{
|
||||
messages.AddRange(conversationHistory);
|
||||
}
|
||||
|
||||
foreach (var inputMessage in request.Input.GetInputMessages())
|
||||
{
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
|
||||
|
||||
@@ -28,10 +29,12 @@ internal interface IResponseExecutor
|
||||
/// </summary>
|
||||
/// <param name="context">The agent invocation context containing the ID generator and other context information.</param>
|
||||
/// <param name="request">The create response request.</param>
|
||||
/// <param name="conversationHistory">Optional prior conversation messages to prepend to the agent's input.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of streaming response events.</returns>
|
||||
IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
IReadOnlyList<ChatMessage>? conversationHistory = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
+18
-1
@@ -425,11 +425,28 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
|
||||
// Create agent invocation context
|
||||
var context = new AgentInvocationContext(new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id));
|
||||
|
||||
// Load conversation history if a conversation ID is provided
|
||||
IReadOnlyList<Extensions.AI.ChatMessage>? conversationHistory = null;
|
||||
if (this._conversationStorage is not null && request.Conversation?.Id is not null)
|
||||
{
|
||||
var itemsResult = await this._conversationStorage.ListItemsAsync(
|
||||
request.Conversation.Id,
|
||||
limit: 100,
|
||||
order: SortOrder.Ascending,
|
||||
cancellationToken: linkedCts.Token).ConfigureAwait(false);
|
||||
|
||||
var history = ItemResourceConversions.ToChatMessages(itemsResult.Data);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
conversationHistory = history;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect output items for conversation storage
|
||||
List<ItemResource> outputItems = [];
|
||||
|
||||
// Execute using the injected executor
|
||||
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, linkedCts.Token).ConfigureAwait(false))
|
||||
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false))
|
||||
{
|
||||
state.AddStreamingEvent(streamingEvent);
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ public sealed class GroupChatWorkflowBuilder
|
||||
{
|
||||
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory;
|
||||
private readonly HashSet<AIAgent> _participants = new(AIAgentIDEqualityComparer.Instance);
|
||||
private string _name = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
|
||||
internal GroupChatWorkflowBuilder(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) =>
|
||||
this._managerFactory = managerFactory;
|
||||
@@ -42,6 +44,28 @@ public sealed class GroupChatWorkflowBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the human-readable name for the workflow.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the workflow.</param>
|
||||
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
|
||||
public GroupChatWorkflowBuilder WithName(string name)
|
||||
{
|
||||
this._name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the description for the workflow.
|
||||
/// </summary>
|
||||
/// <param name="description">The description of what the workflow does.</param>
|
||||
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
|
||||
public GroupChatWorkflowBuilder WithDescription(string description)
|
||||
{
|
||||
this._description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="Workflow"/> composed of agents that operate via group chat, with the next
|
||||
/// agent to process messages selected by the group chat manager.
|
||||
@@ -65,6 +89,16 @@ public sealed class GroupChatWorkflowBuilder
|
||||
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
|
||||
WorkflowBuilder builder = new(host);
|
||||
|
||||
if (!string.IsNullOrEmpty(this._name))
|
||||
{
|
||||
builder = builder.WithName(this._name);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(this._description))
|
||||
{
|
||||
builder = builder.WithDescription(this._description);
|
||||
}
|
||||
|
||||
foreach (var participant in agentMap.Values)
|
||||
{
|
||||
builder
|
||||
|
||||
@@ -153,18 +153,52 @@ public static class WorkflowVisualizer
|
||||
|
||||
private static void EmitWorkflowMermaid(Workflow workflow, List<string> lines, string indent, string? ns = null)
|
||||
{
|
||||
string MapId(string id) => ns != null ? $"{ns}/{id}" : id;
|
||||
// Build a mapping from raw IDs to Mermaid-safe node aliases that preserve
|
||||
// as much of the original ID as possible for readability.
|
||||
// Mermaid node IDs cannot contain spaces, dots, pipes, or most special characters.
|
||||
var aliasMap = new Dictionary<string, string>();
|
||||
var usedAliases = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
string GetSafeId(string id)
|
||||
{
|
||||
var key = ns != null ? $"{ns}/{id}" : id;
|
||||
if (!aliasMap.TryGetValue(key, out var alias))
|
||||
{
|
||||
alias = SanitizeMermaidNodeId(key);
|
||||
|
||||
// Handle collisions by appending a numeric suffix
|
||||
if (!usedAliases.Add(alias))
|
||||
{
|
||||
var i = 2;
|
||||
while (!usedAliases.Add($"{alias}_{i}"))
|
||||
{
|
||||
if (i >= 10_000)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to generate a unique Mermaid node ID for '{key}'.");
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
alias = $"{alias}_{i}";
|
||||
}
|
||||
|
||||
aliasMap[key] = alias;
|
||||
}
|
||||
|
||||
return alias;
|
||||
}
|
||||
|
||||
// Add start node
|
||||
var startExecutorId = workflow.StartExecutorId;
|
||||
lines.Add($"{indent}{MapId(startExecutorId)}[\"{startExecutorId} (Start)\"];");
|
||||
lines.Add($"{indent}{GetSafeId(startExecutorId)}[\"{EscapeMermaidLabel(startExecutorId)} (Start)\"];");
|
||||
|
||||
// Add other executor nodes
|
||||
foreach (var executorId in workflow.ExecutorBindings.Keys)
|
||||
{
|
||||
if (executorId != startExecutorId)
|
||||
{
|
||||
lines.Add($"{indent}{MapId(executorId)}[\"{executorId}\"];");
|
||||
lines.Add($"{indent}{GetSafeId(executorId)}[\"{EscapeMermaidLabel(executorId)}\"];");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +209,7 @@ public static class WorkflowVisualizer
|
||||
lines.Add("");
|
||||
foreach (var (nodeId, _, _) in fanInDescriptors)
|
||||
{
|
||||
lines.Add($"{indent}{MapId(nodeId)}((fan-in))");
|
||||
lines.Add($"{indent}{GetSafeId(nodeId)}((fan-in))");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,9 +218,9 @@ public static class WorkflowVisualizer
|
||||
{
|
||||
foreach (var src in sources)
|
||||
{
|
||||
lines.Add($"{indent}{MapId(src)} --> {MapId(nodeId)};");
|
||||
lines.Add($"{indent}{GetSafeId(src)} --> {GetSafeId(nodeId)};");
|
||||
}
|
||||
lines.Add($"{indent}{MapId(nodeId)} --> {MapId(target)};");
|
||||
lines.Add($"{indent}{GetSafeId(nodeId)} --> {GetSafeId(target)};");
|
||||
}
|
||||
|
||||
// Emit normal edges
|
||||
@@ -197,17 +231,17 @@ public static class WorkflowVisualizer
|
||||
string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
|
||||
|
||||
// Conditional edge, with user label or default
|
||||
lines.Add($"{indent}{MapId(src)} -. {effectiveLabel} .--> {MapId(target)};");
|
||||
lines.Add($"{indent}{GetSafeId(src)} -. {effectiveLabel} .-> {GetSafeId(target)};");
|
||||
}
|
||||
else if (label != null)
|
||||
{
|
||||
// Regular edge with label
|
||||
lines.Add($"{indent}{MapId(src)} -->|{EscapeMermaidLabel(label)}| {MapId(target)};");
|
||||
lines.Add($"{indent}{GetSafeId(src)} -->|{EscapeMermaidLabel(label)}| {GetSafeId(target)};");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular edge without label
|
||||
lines.Add($"{indent}{MapId(src)} --> {MapId(target)};");
|
||||
lines.Add($"{indent}{GetSafeId(src)} --> {GetSafeId(target)};");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,6 +335,50 @@ public static class WorkflowVisualizer
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw node ID into a Mermaid-safe identifier that preserves as much
|
||||
/// of the original text as possible. ASCII letters, digits, and underscores are kept
|
||||
/// as-is (including existing consecutive underscores). All other characters (including
|
||||
/// non-ASCII letters) are replaced with underscores, with consecutive invalid characters
|
||||
/// collapsed into a single underscore. A leading digit gets a prefix.
|
||||
/// </summary>
|
||||
private static string SanitizeMermaidNodeId(string id)
|
||||
{
|
||||
Throw.IfNull(id);
|
||||
|
||||
var sb = new StringBuilder(id.Length);
|
||||
bool lastWasUnderscore = false;
|
||||
foreach (var ch in id)
|
||||
{
|
||||
bool isAsciiSafe = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_';
|
||||
if (isAsciiSafe)
|
||||
{
|
||||
sb.Append(ch);
|
||||
lastWasUnderscore = ch == '_';
|
||||
}
|
||||
else if (!lastWasUnderscore)
|
||||
{
|
||||
sb.Append('_');
|
||||
lastWasUnderscore = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Trim trailing underscore
|
||||
while (sb.Length > 0 && sb[sb.Length - 1] == '_')
|
||||
{
|
||||
sb.Length--;
|
||||
}
|
||||
|
||||
// Mermaid IDs must not start with a digit
|
||||
if (sb.Length > 0 && sb[0] >= '0' && sb[0] <= '9')
|
||||
{
|
||||
sb.Insert(0, "n_");
|
||||
}
|
||||
|
||||
// Guard against empty result (e.g. id was all special chars)
|
||||
return sb.Length == 0 ? "node" : sb.ToString();
|
||||
}
|
||||
|
||||
// Helper method to escape special characters in DOT labels
|
||||
private static string EscapeDotLabel(string label)
|
||||
{
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -15,8 +13,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// and a markdown body with instructions. Resource files referenced in the body are validated at
|
||||
/// discovery time and read from disk on demand.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkill
|
||||
internal sealed class FileAgentSkill
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkill"/> class.
|
||||
@@ -25,8 +22,8 @@ public sealed class FileAgentSkill
|
||||
/// <param name="body">The SKILL.md content after the closing <c>---</c> delimiter.</param>
|
||||
/// <param name="sourcePath">Absolute path to the directory containing this skill.</param>
|
||||
/// <param name="resourceNames">Relative paths of resource files referenced in the skill body.</param>
|
||||
internal FileAgentSkill(
|
||||
FileAgentSkillFrontmatter frontmatter,
|
||||
public FileAgentSkill(
|
||||
SkillFrontmatter frontmatter,
|
||||
string body,
|
||||
string sourcePath,
|
||||
IReadOnlyList<string>? resourceNames = null)
|
||||
@@ -40,20 +37,20 @@ public sealed class FileAgentSkill
|
||||
/// <summary>
|
||||
/// Gets the parsed YAML frontmatter (name and description).
|
||||
/// </summary>
|
||||
public FileAgentSkillFrontmatter Frontmatter { get; }
|
||||
public SkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SKILL.md body content (without the YAML frontmatter).
|
||||
/// </summary>
|
||||
public string Body { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path where the skill was discovered.
|
||||
/// </summary>
|
||||
public string SourcePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SKILL.md body content (without the YAML frontmatter).
|
||||
/// </summary>
|
||||
internal string Body { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md").
|
||||
/// </summary>
|
||||
internal IReadOnlyList<string> ResourceNames { get; }
|
||||
public IReadOnlyList<string> ResourceNames { get; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
@@ -10,7 +9,6 @@ using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
@@ -22,8 +20,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded
|
||||
/// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed partial class FileAgentSkillLoader
|
||||
internal sealed partial class FileAgentSkillLoader
|
||||
{
|
||||
private const string SkillFileName = "SKILL.md";
|
||||
private const int MaxSearchDepth = 2;
|
||||
@@ -36,16 +33,13 @@ public sealed partial class FileAgentSkillLoader
|
||||
// Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
|
||||
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Matches resource file references in skill markdown. Group 1 = relative file path.
|
||||
// Supports two forms:
|
||||
// 1. Markdown links: [text](path/file.ext)
|
||||
// 2. Backtick-quoted paths: `path/file.ext`
|
||||
// Matches markdown links to local resource files. Group 1 = relative file path.
|
||||
// Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class).
|
||||
// Intentionally conservative: only matches paths with word characters, hyphens, dots,
|
||||
// and forward slashes. Paths with spaces or special characters are not supported.
|
||||
// Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", `./scripts/run.py` → "./scripts/run.py",
|
||||
// Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", [s](./s.json) → "./s.json",
|
||||
// [p](../shared/doc.txt) → "../shared/doc.txt"
|
||||
private static readonly Regex s_resourceLinkRegex = new(@"(?:\[.*?\]\(|`)(\.?\.?/?[\w][\w\-./]*\.\w+)(?:\)|`)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
|
||||
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
|
||||
@@ -117,7 +111,7 @@ public sealed partial class FileAgentSkillLoader
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The resource is not registered, resolves outside the skill directory, or does not exist.
|
||||
/// </exception>
|
||||
public async Task<string> ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
|
||||
internal async Task<string> ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
resourceName = NormalizeResourcePath(resourceName);
|
||||
|
||||
@@ -195,7 +189,7 @@ public sealed partial class FileAgentSkillLoader
|
||||
|
||||
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
|
||||
|
||||
if (!this.TryParseSkillDocument(content, skillFilePath, out FileAgentSkillFrontmatter frontmatter, out string body))
|
||||
if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -214,7 +208,7 @@ public sealed partial class FileAgentSkillLoader
|
||||
resourceNames: resourceNames);
|
||||
}
|
||||
|
||||
private bool TryParseSkillDocument(string content, string skillFilePath, out FileAgentSkillFrontmatter frontmatter, out string body)
|
||||
private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body)
|
||||
{
|
||||
frontmatter = null!;
|
||||
body = null!;
|
||||
@@ -270,7 +264,7 @@ public sealed partial class FileAgentSkillLoader
|
||||
return false;
|
||||
}
|
||||
|
||||
frontmatter = new FileAgentSkillFrontmatter(name, description);
|
||||
frontmatter = new SkillFrontmatter(name, description);
|
||||
body = content.Substring(match.Index + match.Length).TrimStart();
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to loaded skills and the skill loader for use by <see cref="FileAgentSkillScriptExecutor"/> implementations.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkillScriptExecutionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillScriptExecutionContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="skills">The loaded skills dictionary.</param>
|
||||
/// <param name="loader">The skill loader for reading resources.</param>
|
||||
internal FileAgentSkillScriptExecutionContext(Dictionary<string, FileAgentSkill> skills, FileAgentSkillLoader loader)
|
||||
{
|
||||
this.Skills = skills;
|
||||
this.Loader = loader;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the loaded skills keyed by name.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, FileAgentSkill> Skills { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the skill loader for reading resources.
|
||||
/// </summary>
|
||||
public FileAgentSkillLoader Loader { get; }
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the tools and instructions contributed by a <see cref="FileAgentSkillScriptExecutor"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkillScriptExecutionDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the additional instructions to provide to the agent for script execution.
|
||||
/// </summary>
|
||||
public string? Instructions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the additional tools to provide to the agent for script execution.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AITool>? Tools { get; set; }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for skill script execution modes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A <see cref="FileAgentSkillScriptExecutor"/> provides the instructions and tools needed to enable
|
||||
/// script execution within an agent skill. Concrete implementations determine how scripts
|
||||
/// are executed (e.g., via the LLM's hosted code interpreter, an external executor, or a hybrid approach).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use the static factory methods to create instances:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="HostedCodeInterpreter"/> — executes scripts using the LLM provider's built-in code interpreter.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public abstract class FileAgentSkillScriptExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="FileAgentSkillScriptExecutor"/> that uses the LLM provider's hosted code interpreter for script execution.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="FileAgentSkillScriptExecutor"/> instance configured for hosted code interpreter execution.</returns>
|
||||
public static FileAgentSkillScriptExecutor HostedCodeInterpreter() => new HostedCodeInterpreterFileAgentSkillScriptExecutor();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the tools and instructions contributed by this executor.
|
||||
/// </summary>
|
||||
/// <param name="context">
|
||||
/// The execution context provided by the skills provider, containing the loaded skills
|
||||
/// and the skill loader for reading resources.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="FileAgentSkillScriptExecutionDetails"/> containing the executor's tools and instructions.</returns>
|
||||
protected internal abstract FileAgentSkillScriptExecutionDetails GetExecutionDetails(FileAgentSkillScriptExecutionContext context);
|
||||
}
|
||||
@@ -48,21 +48,21 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
|
||||
|
||||
<available_skills>
|
||||
{skills}
|
||||
{0}
|
||||
</available_skills>
|
||||
|
||||
When a task aligns with a skill's domain:
|
||||
- Use `load_skill` to retrieve the skill's instructions
|
||||
- Follow the provided guidance
|
||||
- Use `read_skill_resource` to read any references or other files mentioned by the skill, always using the full path as written (e.g. `references/FAQ.md`, not just `FAQ.md`)
|
||||
{executor_instructions}
|
||||
1. Use `load_skill` to retrieve the skill's instructions
|
||||
2. Follow the provided guidance
|
||||
3. Use `read_skill_resource` to read any references or other files mentioned by the skill
|
||||
|
||||
Only load what is needed, when it is needed.
|
||||
""";
|
||||
|
||||
private readonly Dictionary<string, FileAgentSkill> _skills;
|
||||
private readonly ILogger<FileAgentSkillsProvider> _logger;
|
||||
private readonly FileAgentSkillLoader _loader;
|
||||
private readonly IEnumerable<AITool> _tools;
|
||||
private readonly AITool[] _tools;
|
||||
private readonly string? _skillsInstructionPrompt;
|
||||
|
||||
/// <summary>
|
||||
@@ -91,13 +91,9 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
this._loader = new FileAgentSkillLoader(this._logger);
|
||||
this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
|
||||
|
||||
var executionDetails = options?.ScriptExecutor is { } executor
|
||||
? executor.GetExecutionDetails(new(this._skills, this._loader))
|
||||
: null;
|
||||
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
|
||||
|
||||
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills, executionDetails?.Instructions);
|
||||
|
||||
AITool[] baseTools =
|
||||
this._tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
this.LoadSkill,
|
||||
@@ -108,10 +104,6 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
name: "read_skill_resource",
|
||||
description: "Reads a file associated with a skill, such as references or assets."),
|
||||
];
|
||||
|
||||
this._tools = executionDetails?.Tools is { Count: > 0 } executorTools
|
||||
? baseTools.Concat(executorTools)
|
||||
: baseTools;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -125,7 +117,7 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
return new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Instructions = this._skillsInstructionPrompt,
|
||||
Tools = this._tools,
|
||||
Tools = this._tools
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,9 +166,25 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary<string, FileAgentSkill> skills, string? instructions)
|
||||
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary<string, FileAgentSkill> skills)
|
||||
{
|
||||
string promptTemplate = options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
|
||||
string promptTemplate = DefaultSkillsInstructionPrompt;
|
||||
|
||||
if (options?.SkillsInstructionPrompt is { } optionsInstructions)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = string.Format(optionsInstructions, string.Empty);
|
||||
promptTemplate = optionsInstructions;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').",
|
||||
nameof(options),
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (skills.Count == 0)
|
||||
{
|
||||
@@ -195,9 +203,7 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
sb.AppendLine(" </skill>");
|
||||
}
|
||||
|
||||
return promptTemplate
|
||||
.Replace("{skills}", sb.ToString().TrimEnd())
|
||||
.Replace("{executor_instructions}", instructions ?? "\n");
|
||||
return string.Format(promptTemplate, sb.ToString().TrimEnd());
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
|
||||
|
||||
@@ -13,20 +13,8 @@ public sealed class FileAgentSkillsProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a custom system prompt template for advertising skills.
|
||||
/// Use <c>{skills}</c> as the placeholder for the generated skills list and
|
||||
/// <c>{executor_instructions}</c> for executor-provided instructions.
|
||||
/// Use <c>{0}</c> as the placeholder for the generated skills list.
|
||||
/// When <see langword="null"/>, a default template is used.
|
||||
/// </summary>
|
||||
public string? SkillsInstructionPrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the skill executor that enables script execution for loaded skills.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/> (the default), script execution is disabled and skills only provide
|
||||
/// instructions and resources. Set this to a <see cref="FileAgentSkillScriptExecutor"/> instance (e.g.,
|
||||
/// <see cref="FileAgentSkillScriptExecutor.HostedCodeInterpreter()"/>) to enable script execution with
|
||||
/// mode-specific instructions and tools.
|
||||
/// </remarks>
|
||||
public FileAgentSkillScriptExecutor? ScriptExecutor { get; set; }
|
||||
}
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="FileAgentSkillScriptExecutor"/> that uses the LLM provider's hosted code interpreter for script execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This executor directs the LLM to load scripts via <c>read_skill_resource</c> and execute them
|
||||
/// using the provider's built-in code interpreter. A <see cref="HostedCodeInterpreterTool"/> is
|
||||
/// registered to signal the provider to enable its code interpreter sandbox.
|
||||
/// </remarks>
|
||||
internal sealed class HostedCodeInterpreterFileAgentSkillScriptExecutor : FileAgentSkillScriptExecutor
|
||||
{
|
||||
private static readonly FileAgentSkillScriptExecutionDetails s_contribution = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
|
||||
Some skills include executable scripts (e.g., Python files) in their resources.
|
||||
When a skill's instructions reference a script:
|
||||
1. Use `read_skill_resource` to load the script content
|
||||
2. Execute the script using the code interpreter
|
||||
|
||||
""",
|
||||
Tools = [new HostedCodeInterpreterTool()],
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
#pragma warning disable RCS1168 // Parameter name differs from base name
|
||||
protected internal override FileAgentSkillScriptExecutionDetails GetExecutionDetails(FileAgentSkillScriptExecutionContext _) => s_contribution;
|
||||
#pragma warning restore RCS1168 // Parameter name differs from base name
|
||||
}
|
||||
+3
-6
@@ -1,7 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -9,15 +7,14 @@ namespace Microsoft.Agents.AI;
|
||||
/// <summary>
|
||||
/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class FileAgentSkillFrontmatter
|
||||
internal sealed class SkillFrontmatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileAgentSkillFrontmatter"/> class.
|
||||
/// Initializes a new instance of the <see cref="SkillFrontmatter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">Skill name.</param>
|
||||
/// <param name="description">Skill description.</param>
|
||||
internal FileAgentSkillFrontmatter(string name, string description)
|
||||
public SkillFrontmatter(string name, string description)
|
||||
{
|
||||
this.Name = Throw.IfNullOrWhitespace(name);
|
||||
this.Description = Throw.IfNullOrWhitespace(description);
|
||||
+92
@@ -1201,6 +1201,75 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
|
||||
Assert.Null(mockChatClient.LastChatOptions.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that conversation history is passed to the agent on subsequent requests.
|
||||
/// This test reproduces the bug described in GitHub issue #3484.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateResponse_WithConversation_SecondRequestIncludesPriorMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "memory-agent";
|
||||
const string Instructions = "You are a helpful assistant.";
|
||||
const string AgentResponse = "Nice to meet you Alice";
|
||||
|
||||
var mockChatClient = new TestHelpers.ConversationMemoryMockChatClient(AgentResponse);
|
||||
this._httpClient = await this.CreateTestServerWithCustomClientAndConversationsAsync(
|
||||
AgentName, Instructions, mockChatClient);
|
||||
|
||||
// Create a conversation
|
||||
string createConvJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
new { metadata = new { agent_id = AgentName } });
|
||||
using StringContent createConvContent = new(createConvJson, Encoding.UTF8, "application/json");
|
||||
HttpResponseMessage createConvResponse = await this._httpClient.PostAsync(
|
||||
new Uri("/v1/conversations", UriKind.Relative), createConvContent);
|
||||
Assert.True(createConvResponse.IsSuccessStatusCode);
|
||||
|
||||
string convJson = await createConvResponse.Content.ReadAsStringAsync();
|
||||
using var convDoc = System.Text.Json.JsonDocument.Parse(convJson);
|
||||
string conversationId = convDoc.RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Act - First message
|
||||
await this.SendRawResponseAsync(AgentName, "My name is Alice", conversationId, stream: false);
|
||||
|
||||
// Act - Second message in same conversation
|
||||
await this.SendRawResponseAsync(AgentName, "What is my name?", conversationId, stream: false);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, mockChatClient.CallHistory.Count);
|
||||
|
||||
// First call: should have 1 message (just the user input)
|
||||
Assert.Single(mockChatClient.CallHistory[0]);
|
||||
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[0][0].Role);
|
||||
|
||||
// Second call: should have 3 messages (prior user + prior assistant + new user)
|
||||
Assert.Equal(3, mockChatClient.CallHistory[1].Count);
|
||||
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][0].Role);
|
||||
Assert.Equal(ChatRole.Assistant, mockChatClient.CallHistory[1][1].Role);
|
||||
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][2].Role);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendRawResponseAsync(
|
||||
string agentName, string input, string conversationId, bool stream)
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
input,
|
||||
agent = new { name = agentName },
|
||||
conversation = conversationId,
|
||||
stream
|
||||
};
|
||||
string json = System.Text.Json.JsonSerializer.Serialize(requestBody);
|
||||
using StringContent content = new(json, Encoding.UTF8, "application/json");
|
||||
HttpResponseMessage response = await this._httpClient!.PostAsync(
|
||||
new Uri($"/{agentName}/v1/responses", UriKind.Relative), content);
|
||||
Assert.True(response.IsSuccessStatusCode, $"Response failed: {response.StatusCode}");
|
||||
|
||||
// Consume the full response body to ensure execution completes
|
||||
await response.Content.ReadAsStringAsync();
|
||||
return response;
|
||||
}
|
||||
|
||||
private ResponsesClient CreateResponseClient(string agentName)
|
||||
{
|
||||
return new ResponsesClient(
|
||||
@@ -1272,6 +1341,29 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
|
||||
return testServer.CreateClient();
|
||||
}
|
||||
|
||||
private async Task<HttpClient> CreateTestServerWithCustomClientAndConversationsAsync(string agentName, string instructions, IChatClient chatClient)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
builder.Services.AddKeyedSingleton($"chat-client-{agentName}", chatClient);
|
||||
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: $"chat-client-{agentName}");
|
||||
builder.AddOpenAIResponses();
|
||||
builder.AddOpenAIConversations();
|
||||
|
||||
this._app = builder.Build();
|
||||
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
this._app.MapOpenAIResponses(agent);
|
||||
this._app.MapOpenAIConversations();
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
return testServer.CreateClient();
|
||||
}
|
||||
|
||||
private async Task<HttpClient> CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
|
||||
@@ -597,6 +597,86 @@ internal static class TestHelpers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock IChatClient that captures the full message list on each call.
|
||||
/// Used to verify conversation history is passed correctly.
|
||||
/// </summary>
|
||||
internal sealed class ConversationMemoryMockChatClient : IChatClient
|
||||
{
|
||||
private readonly string _responseText;
|
||||
|
||||
/// <summary>Each entry is the messages list received for that call.</summary>
|
||||
public List<List<ChatMessage>> CallHistory { get; } = [];
|
||||
|
||||
public ConversationMemoryMockChatClient(string responseText = "Test response")
|
||||
{
|
||||
this._responseText = responseText;
|
||||
}
|
||||
|
||||
public ChatClientMetadata Metadata { get; } = new("Test", new Uri("https://test.example.com"), "test-model");
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CallHistory.Add(messages.ToList());
|
||||
|
||||
ChatMessage message = new(ChatRole.Assistant, this._responseText);
|
||||
ChatResponse response = new([message])
|
||||
{
|
||||
ModelId = "test-model",
|
||||
FinishReason = ChatFinishReason.Stop,
|
||||
Usage = new UsageDetails
|
||||
{
|
||||
InputTokenCount = 10,
|
||||
OutputTokenCount = 5,
|
||||
TotalTokenCount = 15
|
||||
}
|
||||
};
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CallHistory.Add(messages.ToList());
|
||||
await Task.Delay(1, cancellationToken);
|
||||
|
||||
string[] words = this._responseText.Split(' ');
|
||||
for (int i = 0; i < words.Length; i++)
|
||||
{
|
||||
string content = i < words.Length - 1 ? words[i] + " " : words[i];
|
||||
ChatResponseUpdate update = new()
|
||||
{
|
||||
Contents = [new TextContent(content)],
|
||||
Role = ChatRole.Assistant
|
||||
};
|
||||
|
||||
if (i == words.Length - 1)
|
||||
{
|
||||
update.Contents.Add(new UsageContent(new UsageDetails
|
||||
{
|
||||
InputTokenCount = 10,
|
||||
OutputTokenCount = 5,
|
||||
TotalTokenCount = 15
|
||||
}));
|
||||
}
|
||||
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
serviceType.IsInstanceOfType(this) ? this : null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom content mock implementation of IChatClient that returns custom content based on a provider function.
|
||||
/// </summary>
|
||||
|
||||
+1
-49
@@ -501,7 +501,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
// Manually construct a skill that bypasses discovery validation
|
||||
var frontmatter = new FileAgentSkillFrontmatter("symlink-read-skill", "A skill");
|
||||
var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill");
|
||||
var skill = new FileAgentSkill(
|
||||
frontmatter: frontmatter,
|
||||
body: "See [doc](refs/data.md).",
|
||||
@@ -532,54 +532,6 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.Equal("Body content.", skills["bom-skill"].Body);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("No resource references.", new string[0])]
|
||||
[InlineData("Review `refs/FAQ.md` for details.", new[] { "refs/FAQ.md" })]
|
||||
[InlineData("See [guide](refs/guide.md) then run `scripts/run.py`.", new[] { "refs/guide.md", "scripts/run.py" })]
|
||||
public void DiscoverAndLoadSkills_ResourceReferences_ExtractsExpectedResourceNames(string body, string[] expectedResources)
|
||||
{
|
||||
// Arrange — create skill with resource files on disk so validation passes
|
||||
string skillDir = Path.Combine(this._testRoot, "res-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
foreach (string resource in expectedResources)
|
||||
{
|
||||
string resourcePath = Path.Combine(skillDir, resource.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(resourcePath)!);
|
||||
File.WriteAllText(resourcePath, "content");
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
$"---\nname: res-skill\ndescription: Resource test\n---\n{body}");
|
||||
|
||||
// Act
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills["res-skill"];
|
||||
Assert.Equal(expectedResources.Length, skill.ResourceNames.Count);
|
||||
foreach (string expected in expectedResources)
|
||||
{
|
||||
Assert.Contains(expected, skill.ResourceNames);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResourceAsync_BacktickResourcePath_ReturnsContentAsync()
|
||||
{
|
||||
// Arrange — skill body uses backtick-quoted path
|
||||
_ = this.CreateSkillDirectoryWithResource("backtick-read", "A skill", "Load `refs/doc.md` first.", "refs/doc.md", "Backtick content.");
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
var skill = skills["backtick-read"];
|
||||
|
||||
// Act
|
||||
string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Backtick content.", content);
|
||||
}
|
||||
|
||||
private string CreateSkillDirectory(string name, string description, string body)
|
||||
{
|
||||
string skillDir = Path.Combine(this._testRoot, name);
|
||||
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FileAgentSkillScriptExecutor"/> and its integration with <see cref="FileAgentSkillsProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class FileAgentSkillScriptExecutorTests : IDisposable
|
||||
{
|
||||
private readonly string _testRoot;
|
||||
private readonly TestAIAgent _agent = new();
|
||||
private static readonly FileAgentSkillScriptExecutionContext s_emptyContext = new(
|
||||
new Dictionary<string, FileAgentSkill>(StringComparer.OrdinalIgnoreCase),
|
||||
new FileAgentSkillLoader(NullLogger.Instance));
|
||||
|
||||
public FileAgentSkillScriptExecutorTests()
|
||||
{
|
||||
this._testRoot = Path.Combine(Path.GetTempPath(), "skill-executor-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(this._testRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(this._testRoot))
|
||||
{
|
||||
Directory.Delete(this._testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostedCodeInterpreter_ReturnsNonNullInstance()
|
||||
{
|
||||
// Act
|
||||
var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(executor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostedCodeInterpreter_GetExecutionDetails_ReturnsNonNullInstructions()
|
||||
{
|
||||
// Arrange
|
||||
var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter();
|
||||
|
||||
// Act
|
||||
var details = executor.GetExecutionDetails(s_emptyContext);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(details);
|
||||
Assert.NotNull(details.Instructions);
|
||||
Assert.NotEmpty(details.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostedCodeInterpreter_GetExecutionDetails_ReturnsNonEmptyToolsList()
|
||||
{
|
||||
// Arrange
|
||||
var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter();
|
||||
|
||||
// Act
|
||||
var details = executor.GetExecutionDetails(s_emptyContext);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(details);
|
||||
Assert.NotNull(details.Tools);
|
||||
Assert.NotEmpty(details.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_WithExecutor_IncludesExecutorInstructionsInPromptAsync()
|
||||
{
|
||||
// Arrange
|
||||
CreateSkill(this._testRoot, "exec-skill", "Executor test", "Body.");
|
||||
var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter();
|
||||
var options = new FileAgentSkillsProviderOptions { ScriptExecutor = executor };
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — executor instructions should be merged into the prompt
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("code interpreter", result.Instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_WithExecutor_IncludesExecutorToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
CreateSkill(this._testRoot, "tools-exec-skill", "Executor tools test", "Body.");
|
||||
var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter();
|
||||
var options = new FileAgentSkillsProviderOptions { ScriptExecutor = executor };
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — should have 3 tools: load_skill, read_skill_resource, and HostedCodeInterpreterTool
|
||||
Assert.NotNull(result.Tools);
|
||||
Assert.Equal(3, result.Tools!.Count());
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
Assert.Single(result.Tools!, t => t is HostedCodeInterpreterTool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_WithoutExecutor_DoesNotIncludeExecutorToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
CreateSkill(this._testRoot, "no-exec-skill", "No executor test", "Body.");
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — should only have the two base tools
|
||||
Assert.NotNull(result.Tools);
|
||||
Assert.Equal(2, result.Tools!.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_WithHostedCodeInterpreter_MergesScriptInstructionsIntoPromptAsync()
|
||||
{
|
||||
// Arrange
|
||||
CreateSkill(this._testRoot, "merge-skill", "Merge test", "Body.");
|
||||
var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter();
|
||||
var options = new FileAgentSkillsProviderOptions { ScriptExecutor = executor };
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — prompt should contain both the skill listing and the executor's script instructions
|
||||
Assert.NotNull(result.Instructions);
|
||||
string instructions = result.Instructions!;
|
||||
|
||||
// Skill listing is present
|
||||
Assert.Contains("merge-skill", instructions);
|
||||
Assert.Contains("Merge test", instructions);
|
||||
|
||||
// Hosted code interpreter script instructions are merged into the prompt
|
||||
Assert.Contains("executable scripts", instructions);
|
||||
Assert.Contains("read_skill_resource", instructions);
|
||||
Assert.Contains("Execute the script using the code interpreter", instructions);
|
||||
}
|
||||
|
||||
private static void CreateSkill(string root, string name, string description, string body)
|
||||
{
|
||||
string skillDir = Path.Combine(root, name);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
$"---\nname: {name}\ndescription: {description}\n---\n{body}");
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -96,7 +96,7 @@ public sealed class FileAgentSkillsProviderTests : IDisposable
|
||||
this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body.");
|
||||
var options = new FileAgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "Custom template: {skills}"
|
||||
SkillsInstructionPrompt = "Custom template: {0}"
|
||||
};
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot, options);
|
||||
var inputContext = new AIContext();
|
||||
@@ -108,6 +108,23 @@ public sealed class FileAgentSkillsProviderTests : IDisposable
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.StartsWith("Custom template:", result.Instructions);
|
||||
Assert.Contains("custom-prompt-skill", result.Instructions);
|
||||
Assert.Contains("Custom prompt", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidPromptTemplate_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange — template with unescaped braces and no valid {0} placeholder
|
||||
var options = new FileAgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "Bad template with {unescaped} braces"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<ArgumentException>(() => new FileAgentSkillsProvider(this._testRoot, options));
|
||||
Assert.Contains("SkillsInstructionPrompt", ex.Message);
|
||||
Assert.Equal("options", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HostedCodeInterpreterFileAgentSkillScriptExecutor"/>.
|
||||
/// </summary>
|
||||
public sealed class HostedCodeInterpreterFileAgentSkillScriptExecutorTests
|
||||
{
|
||||
private static readonly FileAgentSkillScriptExecutionContext s_emptyContext = new(
|
||||
new Dictionary<string, FileAgentSkill>(StringComparer.OrdinalIgnoreCase),
|
||||
new FileAgentSkillLoader(NullLogger.Instance));
|
||||
|
||||
[Fact]
|
||||
public void GetExecutionDetails_ReturnsScriptExecutionGuidance()
|
||||
{
|
||||
// Arrange
|
||||
var executor = new HostedCodeInterpreterFileAgentSkillScriptExecutor();
|
||||
|
||||
// Act
|
||||
var details = executor.GetExecutionDetails(s_emptyContext);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(details.Instructions);
|
||||
Assert.Contains("read_skill_resource", details.Instructions);
|
||||
Assert.Contains("code interpreter", details.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetExecutionDetails_ReturnsSingleHostedCodeInterpreterTool()
|
||||
{
|
||||
// Arrange
|
||||
var executor = new HostedCodeInterpreterFileAgentSkillScriptExecutor();
|
||||
|
||||
// Act
|
||||
var details = executor.GetExecutionDetails(s_emptyContext);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(details.Tools);
|
||||
Assert.Single(details.Tools!);
|
||||
Assert.IsType<HostedCodeInterpreterTool>(details.Tools![0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetExecutionDetails_ReturnsSameInstanceOnMultipleCalls()
|
||||
{
|
||||
// Arrange
|
||||
var executor = new HostedCodeInterpreterFileAgentSkillScriptExecutor();
|
||||
|
||||
// Act
|
||||
var details1 = executor.GetExecutionDetails(s_emptyContext);
|
||||
var details2 = executor.GetExecutionDetails(s_emptyContext);
|
||||
|
||||
// Assert — static details should be reused
|
||||
Assert.Same(details1, details2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FactoryMethod_ReturnsHostedCodeInterpreterFileAgentSkillScriptExecutor()
|
||||
{
|
||||
// Act
|
||||
var executor = FileAgentSkillScriptExecutor.HostedCodeInterpreter();
|
||||
|
||||
// Assert
|
||||
Assert.IsType<HostedCodeInterpreterFileAgentSkillScriptExecutor>(executor);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,50 @@ public class AgentWorkflowBuilderTests
|
||||
Assert.Equal(int.MaxValue, manager.MaximumIterationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGroupChat_WithNameAndDescription_SetsWorkflowNameAndDescription()
|
||||
{
|
||||
const string WorkflowName = "Test Group Chat";
|
||||
const string WorkflowDescription = "A test group chat workflow";
|
||||
|
||||
var workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
|
||||
.AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2"))
|
||||
.WithName(WorkflowName)
|
||||
.WithDescription(WorkflowDescription)
|
||||
.Build();
|
||||
|
||||
Assert.Equal(WorkflowName, workflow.Name);
|
||||
Assert.Equal(WorkflowDescription, workflow.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGroupChat_WithNameOnly_SetsWorkflowName()
|
||||
{
|
||||
const string WorkflowName = "Named Group Chat";
|
||||
|
||||
var workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
|
||||
.AddParticipants(new DoubleEchoAgent("agent1"))
|
||||
.WithName(WorkflowName)
|
||||
.Build();
|
||||
|
||||
Assert.Equal(WorkflowName, workflow.Name);
|
||||
Assert.Null(workflow.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGroupChat_WithoutNameOrDescription_DefaultsToNull()
|
||||
{
|
||||
var workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
|
||||
.AddParticipants(new DoubleEchoAgent("agent1"))
|
||||
.Build();
|
||||
|
||||
Assert.Null(workflow.Name);
|
||||
Assert.Null(workflow.Description);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
|
||||
@@ -139,7 +139,7 @@ public sealed class ObservabilityTests : IDisposable
|
||||
await this.TestWorkflowEndToEndActivitiesAsync("Default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Flaky test - temporarily disabled. Tracked in #12345")]
|
||||
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_OffThreadAsync()
|
||||
{
|
||||
await this.TestWorkflowEndToEndActivitiesAsync("OffThread");
|
||||
|
||||
@@ -292,11 +292,14 @@ public class WorkflowVisualizerTests
|
||||
|
||||
var mermaidContent = workflow.ToMermaidString();
|
||||
|
||||
// Conditional edge should be dotted with label
|
||||
mermaidContent.Should().Contain("start -. conditional .--> mid");
|
||||
// Non-conditional edge should be solid
|
||||
// Conditional edge should be dotted with label (using .-> not .-->)
|
||||
mermaidContent.Should().Contain("-. conditional .-> ");
|
||||
// Non-conditional edge should be a specific solid arrow
|
||||
mermaidContent.Should().Contain("mid --> end");
|
||||
mermaidContent.Should().NotContain("end -. conditional");
|
||||
// Display labels should be present
|
||||
mermaidContent.Should().Contain("\"start (Start)\"");
|
||||
mermaidContent.Should().Contain("\"mid\"");
|
||||
mermaidContent.Should().Contain("\"end\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -320,7 +323,7 @@ public class WorkflowVisualizerTests
|
||||
var fanInLines = Array.FindAll(lines, line => line.Contains("((fan-in))"));
|
||||
fanInLines.Should().HaveCount(1);
|
||||
|
||||
// Extract the intermediate node id from the line
|
||||
// Extract the intermediate fan-in node id from the line
|
||||
var fanInLine = fanInLines[0].Trim();
|
||||
var fanInNodeId = fanInLine.Substring(0, fanInLine.IndexOf("((fan-in))", StringComparison.Ordinal)).Trim();
|
||||
fanInNodeId.Should().NotBeNullOrEmpty();
|
||||
@@ -333,6 +336,24 @@ public class WorkflowVisualizerTests
|
||||
// Ensure direct edges are not present
|
||||
mermaidContent.Should().NotContain("s1 --> t");
|
||||
mermaidContent.Should().NotContain("s2 --> t");
|
||||
|
||||
// Display labels should be present
|
||||
mermaidContent.Should().Contain("\"start (Start)\"");
|
||||
mermaidContent.Should().Contain("\"s1\"");
|
||||
mermaidContent.Should().Contain("\"s2\"");
|
||||
mermaidContent.Should().Contain("\"t\"");
|
||||
|
||||
// All node IDs should be safe aliases (ASCII-only identifiers)
|
||||
foreach (var line in mermaidContent.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Contains("[\"") || trimmed.Contains("(("))
|
||||
{
|
||||
var bracketIdx = trimmed.IndexOfAny(['[', '(']);
|
||||
var nodeId = trimmed.Substring(0, bracketIdx);
|
||||
nodeId.Should().MatchRegex("^[a-zA-Z_][a-zA-Z0-9_]*$");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -353,13 +374,14 @@ public class WorkflowVisualizerTests
|
||||
|
||||
var mermaidContent = workflow.ToMermaidString();
|
||||
|
||||
// Check all executors are present
|
||||
mermaidContent.Should().Contain("start[\"start (Start)\"]");
|
||||
mermaidContent.Should().Contain("middle1[\"middle1\"]");
|
||||
mermaidContent.Should().Contain("middle2[\"middle2\"]");
|
||||
mermaidContent.Should().Contain("end[\"end\"]");
|
||||
// Check display labels are present
|
||||
mermaidContent.Should().Contain("\"start (Start)\"");
|
||||
mermaidContent.Should().Contain("\"middle1\"");
|
||||
mermaidContent.Should().Contain("\"middle2\"");
|
||||
mermaidContent.Should().Contain("\"end\"");
|
||||
|
||||
// Check all edges are present
|
||||
// Check that sanitized IDs are used and all edges connect them
|
||||
mermaidContent.Should().Contain("start[\"start (Start)\"]");
|
||||
mermaidContent.Should().Contain("start --> middle1");
|
||||
mermaidContent.Should().Contain("start --> middle2");
|
||||
mermaidContent.Should().Contain("middle1 --> end");
|
||||
@@ -386,15 +408,19 @@ public class WorkflowVisualizerTests
|
||||
|
||||
var mermaidContent = workflow.ToMermaidString();
|
||||
|
||||
// Check conditional edge
|
||||
mermaidContent.Should().Contain("start -. conditional .--> a");
|
||||
|
||||
// Check fan-out edges
|
||||
mermaidContent.Should().Contain("a --> b");
|
||||
mermaidContent.Should().Contain("a --> c");
|
||||
// Check conditional edge uses correct syntax (.-> not .-->)
|
||||
mermaidContent.Should().Contain("-. conditional .->");
|
||||
mermaidContent.Should().NotContain(".-->");
|
||||
|
||||
// Check fan-in (should have intermediate node)
|
||||
mermaidContent.Should().Contain("((fan-in))");
|
||||
|
||||
// Display labels should be present
|
||||
mermaidContent.Should().Contain("\"start (Start)\"");
|
||||
mermaidContent.Should().Contain("\"a\"");
|
||||
mermaidContent.Should().Contain("\"b\"");
|
||||
mermaidContent.Should().Contain("\"c\"");
|
||||
mermaidContent.Should().Contain("\"end\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -411,7 +437,7 @@ public class WorkflowVisualizerTests
|
||||
var mermaidContent = workflow.ToMermaidString();
|
||||
|
||||
// Should escape pipe character
|
||||
mermaidContent.Should().Contain("start -->|High | Low Priority| end");
|
||||
mermaidContent.Should().Contain("-->|High | Low Priority|");
|
||||
// Should not contain unescaped pipe that would break syntax
|
||||
mermaidContent.Should().NotContain("-->|High | Low");
|
||||
}
|
||||
@@ -453,4 +479,88 @@ public class WorkflowVisualizerTests
|
||||
// Should not contain literal newline in the label (but the overall output has newlines between statements)
|
||||
mermaidContent.Should().NotContain("Line 1\nLine 2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WorkflowViz_Mermaid_ConditionalEdge_ArrowSyntax()
|
||||
{
|
||||
// Conditional edges must use "-. label .->" (not ".-->") which is the correct
|
||||
// Mermaid syntax for dotted arrows with labels.
|
||||
var start = new MockExecutor("start");
|
||||
var mid = new MockExecutor("mid");
|
||||
|
||||
static bool Condition(string? msg) => msg == "foo";
|
||||
|
||||
var workflow = new WorkflowBuilder("start")
|
||||
.AddEdge<string>(start, mid, Condition)
|
||||
.Build();
|
||||
|
||||
var mermaidContent = workflow.ToMermaidString();
|
||||
|
||||
// The output should use ".->" not ".-->" for conditional (dotted) edges
|
||||
mermaidContent.Should().NotContain(".-->", because: "'.-->' is invalid Mermaid syntax for dotted arrows; should be '.->'");
|
||||
mermaidContent.Should().Contain("-. conditional .->", because: "'-. label .->' is the correct Mermaid syntax for dotted arrows with labels");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WorkflowViz_Mermaid_IdentifiersWithSpaces()
|
||||
{
|
||||
// Identifiers with spaces must not be used directly as Mermaid node IDs
|
||||
// because spaces cause rendering errors.
|
||||
var executor1 = new MockExecutor("1. User input");
|
||||
var executor2 = new MockExecutor("2. Process data");
|
||||
|
||||
var workflow = new WorkflowBuilder("1. User input")
|
||||
.AddEdge(executor1, executor2)
|
||||
.Build();
|
||||
|
||||
var mermaidContent = workflow.ToMermaidString();
|
||||
|
||||
// Node definitions should use safe aliases as IDs (no spaces), with display names in quotes
|
||||
// Bad: '1. User input["1. User input (Start)"]' — spaces in ID break Mermaid
|
||||
// Good: 'n_1_User_input["1. User input (Start)"]' — alias ID is safe and sanitized
|
||||
|
||||
// Each node definition line (containing ["..."]) should have a space-free ID before the bracket
|
||||
foreach (var line in mermaidContent.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Contains("[\""))
|
||||
{
|
||||
var bracketIdx = trimmed.IndexOf('[');
|
||||
var nodeId = trimmed.Substring(0, bracketIdx);
|
||||
nodeId.Should().NotContain(" ", because: $"Mermaid node IDs must not contain spaces, but got '{nodeId}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WorkflowViz_Mermaid_IdentifiersWithUnicode()
|
||||
{
|
||||
// Non-ASCII characters (e.g. Japanese) in identifiers cause Mermaid rendering errors.
|
||||
var executor1 = new MockExecutor("ユーザー入力");
|
||||
var executor2 = new MockExecutor("データ処理");
|
||||
|
||||
var workflow = new WorkflowBuilder("ユーザー入力")
|
||||
.AddEdge(executor1, executor2)
|
||||
.Build();
|
||||
|
||||
var mermaidContent = workflow.ToMermaidString();
|
||||
|
||||
// The display labels should contain the original names
|
||||
mermaidContent.Should().Contain("ユーザー入力");
|
||||
mermaidContent.Should().Contain("データ処理");
|
||||
|
||||
// But node IDs (before the bracket) should be safe ASCII-only identifiers
|
||||
foreach (var line in mermaidContent.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Contains("[\""))
|
||||
{
|
||||
var bracketIdx = trimmed.IndexOf('[');
|
||||
var nodeId = trimmed.Substring(0, bracketIdx);
|
||||
// Node ID should start with a letter or underscore, followed by ASCII alphanumeric or underscores
|
||||
nodeId.Should().MatchRegex("^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
because: $"Mermaid node IDs should be ASCII-safe, but got '{nodeId}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user