Add Azure AI Foundry Responses hosting adapter

Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework
AIAgents and workflows within Azure Foundry as hosted agents via the
Azure.AI.AgentServer.Responses SDK.

- AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution
- InputConverter: converts Responses API inputs/history to MEAI ChatMessage
- OutputConverter: converts agent response updates to SSE event stream
- ServiceCollectionExtensions: DI registration helpers
- 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM)
- ResponseStreamValidator: SSE protocol validation tool for samples
- FoundryResponsesHosting sample app

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
alliscode
2026-04-03 08:00:28 -07:00
co-authored by Copilot
parent 3fc1d00026
commit 0b1ed03cd0
19 changed files with 5416 additions and 0 deletions
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<NoWarn>$(NoWarn);NU1903;NU1605</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureAIResponses\Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,470 @@
// Copyright (c) Microsoft. All rights reserved.
/// <summary>
/// Static HTML pages served by the sample application.
/// </summary>
internal static class Pages
{
// ═══════════════════════════════════════════════════════════════════════
// Homepage
// ═══════════════════════════════════════════════════════════════════════
internal const string Home = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Foundry Responses Hosting Demos</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; padding: 2rem; }
main { width: 100%; max-width: 700px; }
h1 { font-size: 1.5rem; margin-bottom: .5rem; color: #1a1a1a; }
.subtitle { color: #555; margin-bottom: 2rem; line-height: 1.5; }
.cards { display: flex; flex-direction: column; gap: 1rem; }
.card { background: #fff; border: 1px solid #ddd; border-radius: 10px; padding: 1.5rem; text-decoration: none; color: inherit; transition: box-shadow .15s, transform .15s; }
.card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.1); transform: translateY(-2px); }
.card h2 { font-size: 1.15rem; color: #0066cc; margin-bottom: .4rem; }
.card p { color: #555; line-height: 1.5; font-size: .9rem; }
.card .tags { margin-top: .6rem; display: flex; gap: .4rem; flex-wrap: wrap; }
.card .tag { background: #e8f0fe; color: #1a73e8; padding: .15rem .5rem; border-radius: 12px; font-size: .75rem; }
footer { margin-top: 2rem; font-size: .8rem; color: #999; text-align: center; }
</style>
</head>
<body>
<main>
<h1>🚀 Foundry Responses Hosting</h1>
<p class="subtitle">
Agent-framework agents hosted via the Azure AI Responses Server SDK.<br/>
Each demo registers a different agent and serves it through <code>POST /responses</code>.
</p>
<div class="cards">
<a class="card" href="/tool-demo">
<h2>🔧 Tool Demo</h2>
<p>An agent with local function tools (time, weather) and remote MCP tools from
Microsoft Learn for documentation search.</p>
<div class="tags">
<span class="tag">Local Tools</span>
<span class="tag">MCP</span>
<span class="tag">Microsoft Learn</span>
<span class="tag">Streaming</span>
</div>
</a>
<a class="card" href="/workflow-demo">
<h2>🔀 Workflow Demo</h2>
<p>A triage workflow that routes questions to specialist agents a Code Expert
or a Creative Writer using agent handoffs.</p>
<div class="tags">
<span class="tag">Workflow</span>
<span class="tag">Handoffs</span>
<span class="tag">Multi-Agent</span>
<span class="tag">Triage</span>
</div>
</a>
</div>
<footer>
All demos share the same <code>/responses</code> endpoint.
The <code>model</code> field in the request selects which agent handles it.
</footer>
</main>
</body>
</html>
""";
// ═══════════════════════════════════════════════════════════════════════
// Tool Demo
// ═══════════════════════════════════════════════════════════════════════
internal const string ToolDemo = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Tool Demo Foundry Responses Hosting</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; padding: 2rem; }
main { width: 100%; max-width: 800px; }
h1 { font-size: 1.2rem; margin-bottom: .3rem; color: #333; }
.subtitle { font-size: .85rem; color: #666; margin-bottom: .8rem; }
a.back { font-size: .85rem; color: #0066cc; text-decoration: none; display: inline-block; margin-bottom: 1rem; }
#chat { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; height: 56vh; overflow-y: auto; margin-bottom: 1rem; }
.msg { margin-bottom: .75rem; line-height: 1.6; }
.msg.user { color: #0066cc; }
.msg.assistant { color: #333; }
.msg .role { font-weight: 600; margin-right: .25rem; }
.tool-call { background: #f0f4ff; border-left: 3px solid #4a90d9; padding: .4rem .6rem; margin: .4rem 0; border-radius: 4px; font-size: .85rem; color: #555; font-family: 'Cascadia Code', 'Fira Code', monospace; }
.tool-call .tool-icon { margin-right: .3rem; }
form { display: flex; gap: .5rem; }
input { flex: 1; padding: .6rem .8rem; border: 1px solid #ccc; border-radius: 6px; font-size: 1rem; }
button { padding: .6rem 1.2rem; background: #0066cc; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
button:disabled { opacity: .5; cursor: not-allowed; }
#status { font-size: .85rem; color: #888; margin-top: .5rem; }
.suggestions { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
.suggestions button { padding: .3rem .7rem; font-size: .8rem; background: #e8f0fe; color: #1a73e8; border: 1px solid #c5d8f8; border-radius: 16px; cursor: pointer; }
.suggestions button:hover { background: #d2e3fc; }
</style>
</head>
<body>
<main>
<a class="back" href="/"> Back to demos</a>
<h1>🔧 Tool Demo</h1>
<p class="subtitle">Agent with local tools (time, weather) + Microsoft Learn MCP (docs search)</p>
<div class="suggestions">
<button onclick="sendText('What time is it in Tokyo?')">🕐 Time in Tokyo</button>
<button onclick="sendText('What is the weather in Seattle?')">🌤 Weather in Seattle</button>
<button onclick="sendText('How do I create an Azure Function using the CLI?')">📚 Azure Functions docs</button>
<button onclick="sendText('What is Microsoft Agent Framework?')">📚 Agent Framework</button>
</div>
<div id="chat"></div>
<form id="form">
<input id="input" placeholder="Try: 'What time is it?' or 'Search docs for Azure AI Foundry'" autocomplete="off" autofocus />
<button type="submit">Send</button>
</form>
<div id="status"></div>
</main>
<script src="/js/sse-validator.js"></script>
<script>
const AGENT = 'tool-agent';
const chat = document.getElementById('chat');
const form = document.getElementById('form');
const input = document.getElementById('input');
const status = document.getElementById('status');
function escapeHtml(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function addMsg(role, html) {
const d = document.createElement('div');
d.className = 'msg ' + role; d.innerHTML = html;
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
}
function addToolCall(name) {
const d = document.createElement('div');
d.className = 'tool-call';
d.innerHTML = '<span class="tool-icon">🔧</span> Calling <b>' + escapeHtml(name) + '</b>';
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
}
function sendText(t) { input.value = t; form.dispatchEvent(new Event('submit')); }
form.addEventListener('submit', async e => {
e.preventDefault();
const text = input.value.trim(); if (!text) return;
input.value = '';
addMsg('user', '<span class="role">You:</span>' + escapeHtml(text));
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true; status.textContent = 'Streaming';
let fullText = '', assistantDiv = null;
const toolCalls = {};
const validator = new SseValidator();
try {
const resp = await fetch('/responses', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: AGENT, stream: true, input: text })
});
if (!resp.ok) { status.textContent = 'Error ' + resp.status; btn.disabled = false; return; }
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '', curEvt = null;
while (true) {
const { done, value } = await reader.read(); if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n'); buf = lines.pop();
for (const line of lines) {
if (line.startsWith('event: ')) { curEvt = line.slice(7).trim(); continue; }
if (!line.startsWith('data: ')) continue;
const d = line.slice(6).trim(); if (d === '[DONE]') continue;
try {
const evt = JSON.parse(d);
validator.capture(curEvt || evt.type || 'unknown', d);
curEvt = null;
if (evt.type === 'response.output_item.added' && evt.item?.type === 'function_call') {
const id = evt.item.id;
toolCalls[id] = { name: evt.item.name || '?', args: '', el: addToolCall(evt.item.name || '?') };
status.textContent = 'Calling tool: ' + (evt.item.name || '…');
}
if (evt.type === 'response.function_call_arguments.delta' && evt.item_id && toolCalls[evt.item_id])
toolCalls[evt.item_id].args += (evt.delta || '');
if (evt.type === 'response.function_call_arguments.done' && evt.item_id && toolCalls[evt.item_id]) {
const tc = toolCalls[evt.item_id];
let args = tc.args; try { args = JSON.stringify(JSON.parse(args), null, 0); } catch {}
tc.el.innerHTML = '<span class="tool-icon"></span> Called <b>' + escapeHtml(tc.name) + '</b>(' + escapeHtml(args) + ')';
}
if (evt.type === 'response.output_text.delta') {
if (!assistantDiv) assistantDiv = addMsg('assistant', '<span class="role">Agent:</span>');
fullText += evt.delta;
assistantDiv.innerHTML = '<span class="role">Agent:</span>' + escapeHtml(fullText);
chat.scrollTop = chat.scrollHeight;
status.textContent = 'Streaming';
}
} catch {}
}
}
if (!fullText && !assistantDiv) addMsg('assistant', '<span class="role">Agent:</span><em>(empty)</em>');
status.textContent = '';
} catch (err) { status.textContent = 'Error: ' + err.message; }
if (validator.events.length > 0) {
try { const vr = await validator.validate(); chat.appendChild(validator.renderElement(vr)); chat.scrollTop = chat.scrollHeight; } catch {}
}
btn.disabled = false; input.focus();
});
</script>
</body>
</html>
""";
// ═══════════════════════════════════════════════════════════════════════
// Workflow Demo
// ═══════════════════════════════════════════════════════════════════════
internal const string WorkflowDemo = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Workflow Demo Foundry Responses Hosting</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; padding: 2rem; }
main { width: 100%; max-width: 800px; }
h1 { font-size: 1.2rem; margin-bottom: .3rem; color: #333; }
.subtitle { font-size: .85rem; color: #666; margin-bottom: .8rem; }
a.back { font-size: .85rem; color: #0066cc; text-decoration: none; display: inline-block; margin-bottom: 1rem; }
#chat { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; height: 56vh; overflow-y: auto; margin-bottom: 1rem; }
.msg { margin-bottom: .75rem; line-height: 1.6; }
.msg.user { color: #0066cc; }
.msg.assistant { color: #333; }
.msg .role { font-weight: 600; margin-right: .25rem; }
.workflow-evt { background: #f0f9f0; border-left: 3px solid #4caf50; padding: .4rem .6rem; margin: .4rem 0; border-radius: 4px; font-size: .85rem; color: #555; }
.workflow-evt.failed { background: #fef0f0; border-left-color: #e53935; }
.tool-call { background: #f0f4ff; border-left: 3px solid #4a90d9; padding: .4rem .6rem; margin: .4rem 0; border-radius: 4px; font-size: .85rem; color: #555; font-family: 'Cascadia Code', 'Fira Code', monospace; }
form { display: flex; gap: .5rem; }
input { flex: 1; padding: .6rem .8rem; border: 1px solid #ccc; border-radius: 6px; font-size: 1rem; }
button { padding: .6rem 1.2rem; background: #0066cc; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
button:disabled { opacity: .5; cursor: not-allowed; }
#status { font-size: .85rem; color: #888; margin-top: .5rem; }
.suggestions { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
.suggestions button { padding: .3rem .7rem; font-size: .8rem; background: #e8f0fe; color: #1a73e8; border: 1px solid #c5d8f8; border-radius: 16px; cursor: pointer; }
.suggestions button:hover { background: #d2e3fc; }
.agent-diagram { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; font-size: .85rem; text-align: center; color: #555; }
.agent-diagram .flow { font-size: 1.1rem; letter-spacing: 2px; }
</style>
</head>
<body>
<main>
<a class="back" href="/"> Back to demos</a>
<h1>🔀 Workflow Demo Agent Handoffs</h1>
<p class="subtitle">A triage agent routes your question to a specialist (Code Expert or Creative Writer)</p>
<div class="agent-diagram">
<div class="flow">👤 User 🔀 <b>Triage</b> 💻 <b>Code Expert</b> / <b>Creative Writer</b></div>
</div>
<div class="suggestions">
<button onclick="sendText('Write a Python function to reverse a linked list')">💻 Reverse linked list</button>
<button onclick="sendText('Write me a haiku about cloud computing')"> Cloud haiku</button>
<button onclick="sendText('Explain the difference between async and threads in C#')">💻 Async vs threads</button>
<button onclick="sendText('Write a short story about an AI that learns to paint')"> AI painter story</button>
</div>
<div id="chat"></div>
<form id="form">
<input id="input" placeholder="Ask a coding question or request creative writing…" autocomplete="off" autofocus />
<button type="submit">Send</button>
</form>
<div id="status"></div>
</main>
<script src="/js/sse-validator.js"></script>
<script>
const AGENT = 'triage-workflow';
const chat = document.getElementById('chat');
const form = document.getElementById('form');
const input = document.getElementById('input');
const status = document.getElementById('status');
function escapeHtml(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function addMsg(role, html) {
const d = document.createElement('div');
d.className = 'msg ' + role; d.innerHTML = html;
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
}
function addWorkflowEvent(icon, text, failed) {
const d = document.createElement('div');
d.className = 'workflow-evt' + (failed ? ' failed' : '');
d.innerHTML = icon + ' ' + escapeHtml(text);
chat.appendChild(d); chat.scrollTop = chat.scrollHeight;
}
function addToolCall(name) {
const d = document.createElement('div');
d.className = 'tool-call';
d.innerHTML = '🔀 Handoff: <b>' + escapeHtml(name) + '</b>';
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
}
function sendText(t) { input.value = t; form.dispatchEvent(new Event('submit')); }
form.addEventListener('submit', async e => {
e.preventDefault();
const text = input.value.trim(); if (!text) return;
input.value = '';
addMsg('user', '<span class="role">You:</span>' + escapeHtml(text));
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true; status.textContent = 'Running workflow';
let fullText = '', assistantDiv = null;
const toolCalls = {};
const validator = new SseValidator();
try {
const resp = await fetch('/responses', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: AGENT, stream: true, input: text })
});
if (!resp.ok) { status.textContent = 'Error ' + resp.status; btn.disabled = false; return; }
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '', curEvt = null;
while (true) {
const { done, value } = await reader.read(); if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n'); buf = lines.pop();
for (const line of lines) {
if (line.startsWith('event: ')) { curEvt = line.slice(7).trim(); continue; }
if (!line.startsWith('data: ')) continue;
const d = line.slice(6).trim(); if (d === '[DONE]') continue;
try {
const evt = JSON.parse(d);
validator.capture(curEvt || evt.type || 'unknown', d);
curEvt = null;
// Workflow events (executor invoked/completed/failed)
if (evt.type === 'response.output_item.added' && evt.item?.type === 'workflow_action') {
const s = evt.item.status;
const id = evt.item.action_id || evt.item.actionId || '?';
if (s === 'in_progress' || s === 'InProgress')
addWorkflowEvent('', 'Agent invoked: ' + id);
else if (s === 'completed' || s === 'Completed')
addWorkflowEvent('✅', 'Agent completed: ' + id);
else if (s === 'failed' || s === 'Failed')
addWorkflowEvent('❌', 'Agent failed: ' + id, true);
}
// Handoff function calls
if (evt.type === 'response.output_item.added' && evt.item?.type === 'function_call') {
const id = evt.item.id;
toolCalls[id] = { name: evt.item.name || '?', args: '', el: addToolCall(evt.item.name || '?') };
status.textContent = 'Handoff: ' + (evt.item.name || '…');
}
if (evt.type === 'response.function_call_arguments.delta' && evt.item_id && toolCalls[evt.item_id])
toolCalls[evt.item_id].args += (evt.delta || '');
if (evt.type === 'response.function_call_arguments.done' && evt.item_id && toolCalls[evt.item_id]) {
const tc = toolCalls[evt.item_id];
let args = tc.args; try { args = JSON.stringify(JSON.parse(args), null, 0); } catch {}
tc.el.innerHTML = '🔀 Handoff: <b>' + escapeHtml(tc.name) + '</b>(' + escapeHtml(args) + ')';
}
// Text streaming from the specialist agent
if (evt.type === 'response.output_text.delta') {
if (!assistantDiv) assistantDiv = addMsg('assistant', '<span class="role">Agent:</span>');
fullText += evt.delta;
assistantDiv.innerHTML = '<span class="role">Agent:</span>' + escapeHtml(fullText);
chat.scrollTop = chat.scrollHeight;
status.textContent = 'Streaming';
}
} catch {}
}
}
if (!fullText && !assistantDiv) addMsg('assistant', '<span class="role">Agent:</span><em>(empty)</em>');
status.textContent = '';
} catch (err) { status.textContent = 'Error: ' + err.message; }
if (validator.events.length > 0) {
try { const vr = await validator.validate(); chat.appendChild(validator.renderElement(vr)); chat.scrollTop = chat.scrollHeight; } catch {}
}
btn.disabled = false; input.focus();
});
</script>
</body>
</html>
""";
// ═══════════════════════════════════════════════════════════════════════
// SSE Validator Script (shared by all demo pages)
// ═══════════════════════════════════════════════════════════════════════
internal const string ValidationScript = """
// SseValidator - inline SSE stream validation for Foundry Responses demos
// Captures events during streaming and validates against the API behaviour contract.
(function() {
const style = document.createElement('style');
style.textContent = `
.sse-val { margin: .4rem 0 .6rem; padding: .3rem .5rem; font-size: .75rem; color: #aaa; border-top: 1px dashed #e8e8e8; }
.val-ok { color: #7ab88a; }
.val-err { color: #d47272; font-weight: 500; }
.val-issues { margin: .2rem 0; }
.val-issue { color: #c06060; font-size: .72rem; padding: .1rem 0; }
.val-issue b { color: #b04040; }
.val-at { color: #ccc; font-size: .68rem; }
.val-log summary { cursor: pointer; color: #bbb; font-size: .72rem; }
.val-log-items { max-height: 120px; overflow-y: auto; font-size: .7rem; background: #fafafa;
padding: .3rem; border-radius: 3px; margin-top: .15rem;
font-family: 'Cascadia Code', 'Fira Code', monospace; }
.val-i { color: #ccc; display: inline-block; width: 1.8rem; text-align: right; margin-right: .3rem; }
.val-t { color: #8ab4d0; }
`;
document.head.appendChild(style);
})();
class SseValidator {
constructor() { this.events = []; }
reset() { this.events = []; }
capture(eventType, data) { this.events.push({ eventType, data }); }
async validate() {
const resp = await fetch('/api/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events: this.events })
});
return await resp.json();
}
renderElement(result) {
const el = document.createElement('div');
el.className = 'sse-val';
const n = result.eventCount;
const ok = result.isValid;
const vs = result.violations || [];
const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
let h = ok
? `<span class="val-ok">${n} events all rules passed </span>`
: `<span class="val-err">${n} events ${vs.length} violation(s)</span>`;
if (vs.length) {
h += '<div class="val-issues">';
vs.forEach(v => {
h += `<div class="val-issue"><b>[${esc(v.ruleId)}]</b> ${esc(v.message)} <span class="val-at">#${v.eventIndex}</span></div>`;
});
h += '</div>';
}
h += `<details class="val-log"><summary>Event log (${this.events.length})</summary><div class="val-log-items">`;
this.events.forEach((e, i) => {
h += `<div><span class="val-i">${i}</span> <span class="val-t">${esc(e.eventType)}</span></div>`;
});
h += '</div></details>';
el.innerHTML = h;
return el;
}
}
""";
}
@@ -0,0 +1,183 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates hosting agent-framework agents as Foundry Hosted Agents
// using the Azure AI Responses Server SDK.
//
// Demos:
// / - Homepage listing all demos
// /tool-demo - Agent with local tools + remote MCP tools
// /workflow-demo - Triage workflow routing to specialist agents
//
// Prerequisites:
// - Azure OpenAI resource with a deployed model
//
// Environment variables:
// - AZURE_OPENAI_ENDPOINT - your Azure OpenAI endpoint
// - AZURE_OPENAI_DEPLOYMENT - the model deployment name (default: "gpt-4o")
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.AI.AgentServer.Responses;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.AzureAIResponses;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
var builder = WebApplication.CreateBuilder(args);
// ---------------------------------------------------------------------------
// 1. Register the Azure AI Responses Server SDK
// ---------------------------------------------------------------------------
builder.Services.AddResponsesServer();
// ---------------------------------------------------------------------------
// 2. Create the shared Azure OpenAI chat client
// ---------------------------------------------------------------------------
var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."));
var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o";
var azureClient = new AzureOpenAIClient(endpoint, new DefaultAzureCredential());
IChatClient chatClient = azureClient.GetChatClient(deployment).AsIChatClient();
// ---------------------------------------------------------------------------
// 3. DEMO 1: Tool Agent — local tools + Microsoft Learn MCP
// ---------------------------------------------------------------------------
Console.WriteLine("Connecting to Microsoft Learn MCP server...");
McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new()
{
Endpoint = new Uri("https://learn.microsoft.com/api/mcp"),
Name = "Microsoft Learn MCP",
}));
var mcpTools = await mcpClient.ListToolsAsync();
Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
builder.AddAIAgent(
name: "tool-agent",
instructions: """
You are a helpful assistant hosted as a Foundry Hosted Agent.
You have access to several tools - use them proactively:
- GetCurrentTime: Returns the current date/time in any timezone.
- GetWeather: Returns weather conditions for any location.
- Microsoft Learn MCP tools: Search and fetch Microsoft documentation.
When a user asks a technical question about Microsoft products, use the
documentation search tools to give accurate, up-to-date answers.
""",
chatClient: chatClient)
.WithAITool(AIFunctionFactory.Create(GetCurrentTime))
.WithAITool(AIFunctionFactory.Create(GetWeather))
.WithAITools(mcpTools.Cast<AITool>().ToArray());
// ---------------------------------------------------------------------------
// 4. DEMO 2: Triage Workflow — routes to specialist agents
// ---------------------------------------------------------------------------
ChatClientAgent triageAgent = new(
chatClient,
instructions: """
You are a triage agent that determines which specialist to hand off to.
Based on the user's question, ALWAYS hand off to one of the available agents.
Do NOT answer the question yourself - just route it.
""",
name: "triage_agent",
description: "Routes messages to the appropriate specialist agent");
ChatClientAgent codeExpert = new(
chatClient,
instructions: """
You are a coding and technology expert. You help with programming questions,
explain technical concepts, debug code, and suggest best practices.
Provide clear, well-structured answers with code examples when appropriate.
""",
name: "code_expert",
description: "Specialist agent for programming and technology questions");
ChatClientAgent creativeWriter = new(
chatClient,
instructions: """
You are a creative writing specialist. You help write stories, poems,
marketing copy, emails, and other creative content. You have a flair
for engaging language and vivid descriptions.
""",
name: "creative_writer",
description: "Specialist agent for creative writing and content tasks");
Workflow triageWorkflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triageAgent)
.WithHandoffs(triageAgent, [codeExpert, creativeWriter])
.WithHandoffs([codeExpert, creativeWriter], triageAgent)
.Build();
builder.AddAIAgent("triage-workflow", (_, key) =>
triageWorkflow.AsAIAgent(name: key));
// ---------------------------------------------------------------------------
// 5. Wire up the agent-framework handler as the IResponseHandler
// ---------------------------------------------------------------------------
builder.Services.AddAgentFrameworkHandler();
var app = builder.Build();
// Dispose the MCP client on shutdown
app.Lifetime.ApplicationStopping.Register(() =>
mcpClient.DisposeAsync().AsTask().GetAwaiter().GetResult());
// ---------------------------------------------------------------------------
// 6. Routes
// ---------------------------------------------------------------------------
app.MapGet("/ready", () => Results.Ok("ready"));
app.MapResponsesServer();
app.MapGet("/", () => Results.Content(Pages.Home, "text/html"));
app.MapGet("/tool-demo", () => Results.Content(Pages.ToolDemo, "text/html"));
app.MapGet("/workflow-demo", () => Results.Content(Pages.WorkflowDemo, "text/html"));
app.MapGet("/js/sse-validator.js", () => Results.Content(Pages.ValidationScript, "application/javascript"));
// Validation endpoint: accepts captured SSE lines and validates them
app.MapPost("/api/validate", (FoundryResponsesHosting.CapturedSseStream captured) =>
{
var validator = new FoundryResponsesHosting.ResponseStreamValidator();
foreach (var evt in captured.Events)
{
validator.ProcessEvent(evt.EventType, evt.Data);
}
validator.Complete();
return Results.Json(validator.GetResult());
});
app.Run();
// ---------------------------------------------------------------------------
// Local tool definitions
// ---------------------------------------------------------------------------
[Description("Gets the current date and time in the specified timezone.")]
static string GetCurrentTime(
[Description("IANA timezone (e.g. 'America/New_York', 'Europe/London', 'UTC'). Defaults to UTC.")]
string timezone = "UTC")
{
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(timezone);
return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).ToString("F");
}
catch
{
return DateTime.UtcNow.ToString("F") + " (UTC - unknown timezone: " + timezone + ")";
}
}
[Description("Gets the current weather for a location. Returns temperature, conditions, and humidity.")]
static string GetWeather(
[Description("The city or location (e.g. 'Seattle', 'London, UK').")]
string location)
{
// Simulated weather - deterministic per location for demo consistency
var rng = new Random(location.ToUpperInvariant().GetHashCode());
var temp = rng.Next(-5, 35);
string[] conditions = ["sunny", "partly cloudy", "overcast", "rainy", "snowy", "windy", "foggy"];
var condition = conditions[rng.Next(conditions.Length)];
return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h.";
}
@@ -0,0 +1,12 @@
{
"profiles": {
"FoundryResponsesHosting": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:54747;http://localhost:54748"
}
}
}
@@ -0,0 +1,601 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FoundryResponsesHosting;
/// <summary>Captured SSE event for validation.</summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")]
internal sealed record CapturedSseEvent(
[property: JsonPropertyName("eventType")] string EventType,
[property: JsonPropertyName("data")] string Data);
/// <summary>Captured SSE stream sent from the client for server-side validation.</summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")]
internal sealed record CapturedSseStream(
[property: JsonPropertyName("events")] List<CapturedSseEvent> Events);
/// <summary>
/// Validates an SSE event stream from the Azure AI Responses Server SDK against
/// the API behaviour contract. Feed events sequentially via <see cref="ProcessEvent"/>
/// and call <see cref="Complete"/> when the stream ends.
/// </summary>
internal sealed class ResponseStreamValidator
{
private readonly List<ValidationViolation> _violations = [];
private int _eventCount;
private int _expectedSequenceNumber;
private StreamState _state = StreamState.Initial;
private string? _responseId;
private readonly HashSet<int> _addedItemIndices = [];
private readonly HashSet<int> _doneItemIndices = [];
private readonly HashSet<string> _addedContentParts = []; // "outputIdx:partIdx"
private readonly HashSet<string> _doneContentParts = [];
private readonly Dictionary<string, string> _textAccumulators = []; // "outputIdx:contentIdx" → accumulated text
private bool _hasTerminal;
/// <summary>All violations found so far.</summary>
internal IReadOnlyList<ValidationViolation> Violations => _violations;
/// <summary>
/// Processes a single SSE event line pair (event type + JSON data).
/// </summary>
/// <param name="eventType">The SSE event type (e.g. "response.created").</param>
/// <param name="jsonData">The raw JSON data payload.</param>
internal void ProcessEvent(string eventType, string jsonData)
{
JsonElement data;
try
{
data = JsonDocument.Parse(jsonData).RootElement;
}
catch (JsonException ex)
{
Fail("PARSE-01", $"Invalid JSON in event data: {ex.Message}");
return;
}
_eventCount++;
// ── Sequence number validation ──────────────────────────────────
if (data.TryGetProperty("sequence_number", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number)
{
int seq = seqProp.GetInt32();
if (seq != _expectedSequenceNumber)
{
Fail("SEQ-01", $"Expected sequence_number {_expectedSequenceNumber}, got {seq}");
}
_expectedSequenceNumber = seq + 1;
}
else if (_state != StreamState.Initial || eventType != "error")
{
// Pre-creation error events may not have sequence_number
Fail("SEQ-02", $"Missing sequence_number on event '{eventType}'");
}
// ── Post-terminal guard ─────────────────────────────────────────
if (_hasTerminal)
{
Fail("TERM-01", $"Event '{eventType}' received after terminal event");
return;
}
// ── Dispatch by event type ──────────────────────────────────────
switch (eventType)
{
case "response.created":
ValidateResponseCreated(data);
break;
case "response.queued":
ValidateStateTransition(eventType, StreamState.Created, StreamState.Queued);
ValidateResponseEnvelope(data, eventType);
break;
case "response.in_progress":
if (_state is StreamState.Created or StreamState.Queued)
{
_state = StreamState.InProgress;
}
else
{
Fail("ORDER-02", $"'response.in_progress' received in state {_state} (expected Created or Queued)");
}
ValidateResponseEnvelope(data, eventType);
break;
case "response.output_item.added":
case "output_item.added":
ValidateInProgress(eventType);
ValidateOutputItemAdded(data);
break;
case "response.output_item.done":
case "output_item.done":
ValidateInProgress(eventType);
ValidateOutputItemDone(data);
break;
case "response.content_part.added":
case "content_part.added":
ValidateInProgress(eventType);
ValidateContentPartAdded(data);
break;
case "response.content_part.done":
case "content_part.done":
ValidateInProgress(eventType);
ValidateContentPartDone(data);
break;
case "response.output_text.delta":
case "output_text.delta":
ValidateInProgress(eventType);
ValidateTextDelta(data);
break;
case "response.output_text.done":
case "output_text.done":
ValidateInProgress(eventType);
ValidateTextDone(data);
break;
case "response.function_call_arguments.delta":
case "function_call_arguments.delta":
ValidateInProgress(eventType);
break;
case "response.function_call_arguments.done":
case "function_call_arguments.done":
ValidateInProgress(eventType);
break;
case "response.completed":
ValidateTerminal(data, "completed");
break;
case "response.failed":
ValidateTerminal(data, "failed");
break;
case "response.incomplete":
ValidateTerminal(data, "incomplete");
break;
case "error":
// Pre-creation error — standalone, no response.created precedes it
if (_state != StreamState.Initial)
{
Fail("ERR-01", "'error' event received after response.created — should use response.failed instead");
}
_hasTerminal = true;
break;
default:
// Unknown events are not violations — the spec may evolve
break;
}
}
/// <summary>
/// Call after the stream ends. Checks that a terminal event was received.
/// </summary>
internal void Complete()
{
if (!_hasTerminal && _state != StreamState.Initial)
{
Fail("TERM-02", "Stream ended without a terminal event (response.completed, response.failed, or response.incomplete)");
}
if (_state == StreamState.Initial && _eventCount == 0)
{
Fail("EMPTY-01", "No events received in the stream");
}
// Check for output items that were added but never completed
foreach (int idx in _addedItemIndices)
{
if (!_doneItemIndices.Contains(idx))
{
Fail("ITEM-03", $"Output item at index {idx} was added but never received output_item.done");
}
}
// Check for content parts that were added but never completed
foreach (string key in _addedContentParts)
{
if (!_doneContentParts.Contains(key))
{
Fail("CONTENT-03", $"Content part '{key}' was added but never received content_part.done");
}
}
}
/// <summary>
/// Returns a summary of all validation results.
/// </summary>
internal ValidationResult GetResult()
{
return new ValidationResult(
EventCount: _eventCount,
IsValid: _violations.Count == 0,
Violations: [.. _violations]);
}
// ═══════════════════════════════════════════════════════════════════════
// Event-specific validators
// ═══════════════════════════════════════════════════════════════════════
private void ValidateResponseCreated(JsonElement data)
{
if (_state != StreamState.Initial)
{
Fail("ORDER-01", $"'response.created' received in state {_state} (expected Initial — must be first event)");
return;
}
_state = StreamState.Created;
// Must have a response envelope
if (!data.TryGetProperty("response", out var resp))
{
Fail("FIELD-01", "'response.created' missing 'response' object");
return;
}
// Required response fields
ValidateRequiredResponseFields(resp, "response.created");
// Capture response ID for cross-event checks
if (resp.TryGetProperty("id", out var idProp))
{
_responseId = idProp.GetString();
}
// Status must be non-terminal
if (resp.TryGetProperty("status", out var statusProp))
{
string? status = statusProp.GetString();
if (status is "completed" or "failed" or "incomplete" or "cancelled")
{
Fail("STATUS-01", $"'response.created' has terminal status '{status}' — must be 'queued' or 'in_progress'");
}
}
}
private void ValidateTerminal(JsonElement data, string expectedKind)
{
if (_state is StreamState.Initial or StreamState.Created)
{
Fail("ORDER-03", $"Terminal event 'response.{expectedKind}' received before 'response.in_progress'");
}
_hasTerminal = true;
_state = StreamState.Terminal;
if (!data.TryGetProperty("response", out var resp))
{
Fail("FIELD-01", $"'response.{expectedKind}' missing 'response' object");
return;
}
ValidateRequiredResponseFields(resp, $"response.{expectedKind}");
if (resp.TryGetProperty("status", out var statusProp))
{
string? status = statusProp.GetString();
// completed_at validation (B6)
bool hasCompletedAt = resp.TryGetProperty("completed_at", out var catProp)
&& catProp.ValueKind != JsonValueKind.Null;
if (status == "completed" && !hasCompletedAt)
{
Fail("FIELD-02", "'completed_at' must be non-null when status is 'completed'");
}
if (status != "completed" && hasCompletedAt)
{
Fail("FIELD-03", $"'completed_at' must be null when status is '{status}'");
}
// error field validation
bool hasError = resp.TryGetProperty("error", out var errProp)
&& errProp.ValueKind != JsonValueKind.Null;
if (status == "failed" && !hasError)
{
Fail("FIELD-04", "'error' must be non-null when status is 'failed'");
}
if (status is "completed" or "incomplete" && hasError)
{
Fail("FIELD-05", $"'error' must be null when status is '{status}'");
}
// error structure validation
if (hasError)
{
ValidateErrorObject(errProp, $"response.{expectedKind}");
}
// cancelled output must be empty (B11)
if (status == "cancelled" && resp.TryGetProperty("output", out var outputProp)
&& outputProp.ValueKind == JsonValueKind.Array && outputProp.GetArrayLength() > 0)
{
Fail("CANCEL-01", "Cancelled response must have empty output array (B11)");
}
// response ID consistency
if (_responseId is not null && resp.TryGetProperty("id", out var idProp)
&& idProp.GetString() != _responseId)
{
Fail("ID-01", $"Response ID changed: was '{_responseId}', now '{idProp.GetString()}'");
}
}
// Usage validation (optional, but if present must be structured correctly)
if (resp.TryGetProperty("usage", out var usageProp) && usageProp.ValueKind == JsonValueKind.Object)
{
ValidateUsage(usageProp, $"response.{expectedKind}");
}
}
private void ValidateOutputItemAdded(JsonElement data)
{
if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number)
{
int index = idxProp.GetInt32();
if (!_addedItemIndices.Add(index))
{
Fail("ITEM-01", $"Duplicate output_item.added for output_index {index}");
}
}
else
{
Fail("FIELD-06", "output_item.added missing 'output_index' field");
}
if (!data.TryGetProperty("item", out _))
{
Fail("FIELD-07", "output_item.added missing 'item' object");
}
}
private void ValidateOutputItemDone(JsonElement data)
{
if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number)
{
int index = idxProp.GetInt32();
if (!_addedItemIndices.Contains(index))
{
Fail("ITEM-02", $"output_item.done for output_index {index} without preceding output_item.added");
}
_doneItemIndices.Add(index);
}
else
{
Fail("FIELD-06", "output_item.done missing 'output_index' field");
}
}
private void ValidateContentPartAdded(JsonElement data)
{
string key = GetContentPartKey(data);
if (!_addedContentParts.Add(key))
{
Fail("CONTENT-01", $"Duplicate content_part.added for {key}");
}
}
private void ValidateContentPartDone(JsonElement data)
{
string key = GetContentPartKey(data);
if (!_addedContentParts.Contains(key))
{
Fail("CONTENT-02", $"content_part.done for {key} without preceding content_part.added");
}
_doneContentParts.Add(key);
}
private void ValidateTextDelta(JsonElement data)
{
string key = GetTextKey(data);
string delta = data.TryGetProperty("delta", out var deltaProp)
? deltaProp.GetString() ?? string.Empty
: string.Empty;
if (!_textAccumulators.TryGetValue(key, out string? existing))
{
_textAccumulators[key] = delta;
}
else
{
_textAccumulators[key] = existing + delta;
}
}
private void ValidateTextDone(JsonElement data)
{
string key = GetTextKey(data);
string? finalText = data.TryGetProperty("text", out var textProp)
? textProp.GetString()
: null;
if (finalText is null)
{
Fail("TEXT-01", $"output_text.done for {key} missing 'text' field");
return;
}
if (_textAccumulators.TryGetValue(key, out string? accumulated) && accumulated != finalText)
{
Fail("TEXT-02", $"output_text.done text for {key} does not match accumulated deltas (accumulated {accumulated.Length} chars, done has {finalText.Length} chars)");
}
}
// ═══════════════════════════════════════════════════════════════════════
// Shared field validators
// ═══════════════════════════════════════════════════════════════════════
private void ValidateRequiredResponseFields(JsonElement resp, string context)
{
if (!HasNonNullString(resp, "id"))
{
Fail("FIELD-01", $"{context}: response missing 'id'");
}
if (resp.TryGetProperty("object", out var objProp))
{
if (objProp.GetString() != "response")
{
Fail("FIELD-08", $"{context}: response.object must be 'response', got '{objProp.GetString()}'");
}
}
else
{
Fail("FIELD-08", $"{context}: response missing 'object' field");
}
if (!resp.TryGetProperty("created_at", out var catProp) || catProp.ValueKind == JsonValueKind.Null)
{
Fail("FIELD-09", $"{context}: response missing 'created_at'");
}
if (!resp.TryGetProperty("status", out _))
{
Fail("FIELD-10", $"{context}: response missing 'status'");
}
if (!resp.TryGetProperty("output", out var outputProp) || outputProp.ValueKind != JsonValueKind.Array)
{
Fail("FIELD-11", $"{context}: response missing 'output' array");
}
}
private void ValidateErrorObject(JsonElement error, string context)
{
if (!HasNonNullString(error, "code"))
{
Fail("ERR-02", $"{context}: error object missing 'code' field");
}
if (!HasNonNullString(error, "message"))
{
Fail("ERR-03", $"{context}: error object missing 'message' field");
}
}
private void ValidateUsage(JsonElement usage, string context)
{
if (!usage.TryGetProperty("input_tokens", out _))
{
Fail("USAGE-01", $"{context}: usage missing 'input_tokens'");
}
if (!usage.TryGetProperty("output_tokens", out _))
{
Fail("USAGE-02", $"{context}: usage missing 'output_tokens'");
}
if (!usage.TryGetProperty("total_tokens", out _))
{
Fail("USAGE-03", $"{context}: usage missing 'total_tokens'");
}
}
private void ValidateResponseEnvelope(JsonElement data, string eventType)
{
if (!data.TryGetProperty("response", out var resp))
{
Fail("FIELD-01", $"'{eventType}' missing 'response' object");
return;
}
ValidateRequiredResponseFields(resp, eventType);
// Response ID consistency
if (_responseId is not null && resp.TryGetProperty("id", out var idProp)
&& idProp.GetString() != _responseId)
{
Fail("ID-01", $"Response ID changed: was '{_responseId}', now '{idProp.GetString()}'");
}
}
// ═══════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════
private void ValidateInProgress(string eventType)
{
if (_state != StreamState.InProgress)
{
Fail("ORDER-04", $"'{eventType}' received in state {_state} (expected InProgress)");
}
}
private void ValidateStateTransition(string eventType, StreamState expected, StreamState next)
{
if (_state != expected)
{
Fail("ORDER-05", $"'{eventType}' received in state {_state} (expected {expected})");
}
else
{
_state = next;
}
}
private void Fail(string ruleId, string message)
{
_violations.Add(new ValidationViolation(ruleId, message, _eventCount));
}
private static bool HasNonNullString(JsonElement obj, string property)
{
return obj.TryGetProperty(property, out var prop)
&& prop.ValueKind == JsonValueKind.String
&& !string.IsNullOrEmpty(prop.GetString());
}
private static string GetContentPartKey(JsonElement data)
{
int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1;
int partIdx = data.TryGetProperty("content_index", out var pi) ? pi.GetInt32() : -1;
return $"{outputIdx}:{partIdx}";
}
private static string GetTextKey(JsonElement data)
{
int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1;
int contentIdx = data.TryGetProperty("content_index", out var ci) ? ci.GetInt32() : -1;
return $"{outputIdx}:{contentIdx}";
}
private enum StreamState
{
Initial,
Created,
Queued,
InProgress,
Terminal,
}
}
/// <summary>A single validation violation.</summary>
/// <param name="RuleId">The rule identifier (e.g. SEQ-01, FIELD-02).</param>
/// <param name="Message">Human-readable description of the violation.</param>
/// <param name="EventIndex">1-based index of the event that triggered this violation.</param>
internal sealed record ValidationViolation(string RuleId, string Message, int EventIndex);
/// <summary>Overall validation result.</summary>
/// <param name="EventCount">Total number of events processed.</param>
/// <param name="IsValid">True if no violations were found.</param>
/// <param name="Violations">List of all violations.</param>
internal sealed record ValidationResult(int EventCount, bool IsValid, IReadOnlyList<ValidationViolation> Violations);