mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee043d17e | ||
|
|
5c52926123 | ||
|
|
02e5600ede | ||
|
|
9361cda413 | ||
|
|
ef86fb51d5 | ||
|
|
d5c07f2623 | ||
|
|
ae989b92e7 | ||
|
|
3242d8a4c4 | ||
|
|
e1e6e3d35e | ||
|
|
08697f8037 | ||
|
|
b0f5fa541c | ||
|
|
e3290a2d22 | ||
|
|
200488cb08 | ||
|
|
bd4fc64b4d | ||
|
|
b2e77067e9 | ||
|
|
08541ee5a9 | ||
|
|
dc4bafbc1e | ||
|
|
de6d0267f2 | ||
|
|
0099a6e2fa | ||
|
|
793403f3db | ||
|
|
9fdd7429a8 | ||
|
|
abc9b60ec9 | ||
|
|
6bc0dc5911 | ||
|
|
cf91819625 | ||
|
|
578416a379 | ||
|
|
c82c0133fc | ||
|
|
950673ba47 | ||
|
|
5ac864dfd9 | ||
|
|
b559545fa4 | ||
|
|
8e54f0b0e7 | ||
|
|
afd2739e38 | ||
|
|
c8b8198af1 | ||
|
|
bda40ba0e1 | ||
|
|
46ed66cfd5 | ||
|
|
289cafcf36 | ||
|
|
46326b6b93 | ||
|
|
4050107942 | ||
|
|
a12cc3878e | ||
|
|
47f5c3397f |
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "Python 3",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.13-bullseye",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.14-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/va-h/devcontainers-features/uv:1": {},
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.8": {}
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:3": {},
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
|
||||
"ghcr.io/devcontainers/features/copilot-cli:1": {}
|
||||
},
|
||||
"postCreateCommand": "bash ./devsetup.sh",
|
||||
"workspaceFolder": "/workspaces/agent-framework/python/",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"GitHub.copilot",
|
||||
"GitHub.vscode-github-actions",
|
||||
"ms-python.python",
|
||||
"ms-windows-ai-studio.windows-ai-studio",
|
||||
"littlefoxteam.vscode-python-test-adapter"
|
||||
|
||||
@@ -8,7 +8,7 @@ ignorePatterns:
|
||||
- pattern: "./blob"
|
||||
- pattern: "./issues"
|
||||
- pattern: "./discussions"
|
||||
- pattern: "./pulls"
|
||||
- pattern: "./pull"
|
||||
- pattern: "https:\/\/platform.openai.com"
|
||||
- pattern: "http:\/\/localhost"
|
||||
- pattern: "http:\/\/127.0.0.1"
|
||||
|
||||
@@ -137,6 +137,7 @@ jobs:
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
<Folder Name="/Samples/02-agents/Harness/">
|
||||
<File Path="samples/02-agents/Harness/README.md" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console_OpenAI/Harness_Shared_Console_OpenAI.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
|
||||
@@ -211,6 +212,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
|
||||
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
|
||||
@@ -280,6 +282,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/03-workflows/Orchestration/">
|
||||
<Project Path="samples/03-workflows/Orchestration/Handoff/Handoff.csproj" />
|
||||
<Project Path="samples/03-workflows/Orchestration/Magentic/Magentic.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/03-workflows/Observability/">
|
||||
<Project Path="samples/03-workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
|
||||
@@ -356,6 +359,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/HostedAgentSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/DurableAgents/" />
|
||||
<Folder Name="/Samples/04-hosting/DurableAgents/AzureFunctions/">
|
||||
<File Path="samples/04-hosting/DurableAgents/AzureFunctions/.editorconfig" />
|
||||
@@ -600,6 +606,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
|
||||
@@ -653,6 +660,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mcp.UnitTests/Microsoft.Agents.AI.Mcp.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
|
||||
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj",
|
||||
"src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
||||
|
||||
@@ -1151,6 +1151,25 @@ internal static class AgentsSamples
|
||||
SkipReason = "Runs as an MCP stdio server that does not exit on its own.",
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_MCP_LongRunningTask_Client",
|
||||
ProjectPath = "samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client",
|
||||
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
|
||||
MustContain =
|
||||
[
|
||||
"=== Transparent long-running MCP task (RunAsync) ===",
|
||||
"=== Transparent long-running MCP task (RunStreamingAsync) ===",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show an agent analyzing a dataset named 'sales-2025-q1' and producing a summary mentioning rows, revenue, anomalies, or outliers.",
|
||||
"The output should contain both a non-streaming response (after RunAsync) and a streaming response (after RunStreamingAsync) for the same analysis question.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "AGUI_Step01_GettingStarted_Client",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.6.1</VersionPrefix>
|
||||
<VersionPrefix>1.7.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260514</DateSuffix>
|
||||
<DateSuffix>260526</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.6.1</GitTag>
|
||||
<GitTag>1.7.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -24,6 +24,7 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM
|
||||
var skillsProvider = new AgentSkillsProvider(
|
||||
Path.Combine(AppContext.BaseDirectory, "skills"),
|
||||
SubprocessScriptRunner.RunAsync);
|
||||
|
||||
// --- Agent Setup ---
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.GetResponsesClient()
|
||||
|
||||
@@ -51,7 +51,7 @@ Console.WriteLine($"Agent: {response.Text}");
|
||||
/// Properties annotated with <see cref="AgentSkillResourceAttribute"/> are automatically
|
||||
/// discovered as skill resources, and methods annotated with <see cref="AgentSkillScriptAttribute"/>
|
||||
/// are automatically discovered as skill scripts. Alternatively,
|
||||
/// <see cref="AgentSkill.Resources"/> and <see cref="AgentSkill.Scripts"/> can be overridden.
|
||||
/// <see cref="AgentClassSkill{TSelf}.Resources"/> and <see cref="AgentClassSkill{TSelf}.Scripts"/> can be overridden.
|
||||
/// </remarks>
|
||||
internal sealed class UnitConverterSkill : AgentClassSkill<UnitConverterSkill>
|
||||
{
|
||||
|
||||
@@ -40,8 +40,8 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
foreach (string line in props.Title.Split('\n'))
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(line);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
row++;
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,6 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
for (int i = 0; i < totalItems; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
|
||||
bool isSelected = i == props.SelectedIndex;
|
||||
bool isCustomTextOption = props.CustomTextPlaceholder != null && i == props.Items.Count;
|
||||
@@ -72,6 +71,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
}
|
||||
|
||||
Console.Write(props.Items[i]);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
@@ -101,6 +101,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
}
|
||||
|
||||
Console.Write(props.CustomText);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
@@ -121,6 +122,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
|
||||
Console.Write(" ");
|
||||
Console.Write(props.CustomTextPlaceholder);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +17,19 @@ public record TextScrollPanelProps : ConsoleReactiveProps
|
||||
/// <summary>
|
||||
/// State for <see cref="TextScrollPanel"/>.
|
||||
/// </summary>
|
||||
/// <param name="RenderedCount">The number of items already rendered.</param>
|
||||
public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState;
|
||||
public record TextScrollPanelState : ConsoleReactiveState;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders pre-rendered string items within a scroll area.
|
||||
/// All items are considered finalized — only new items since the last render are output.
|
||||
/// Use <see cref="Reset"/> to force a full re-render.
|
||||
/// The last rendered item is considered dynamic and will be re-rendered on each call.
|
||||
/// All prior items are considered finalized and are not re-rendered.
|
||||
/// Use <see cref="Invalidate"/> to force a full re-render.
|
||||
/// </summary>
|
||||
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
|
||||
{
|
||||
private int _renderedCount;
|
||||
private int _lastItemOffsetFromBottom;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
|
||||
/// </summary>
|
||||
@@ -35,12 +38,12 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
this.State = new TextScrollPanelState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the panel so all items will be re-rendered on the next Render call.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
/// <inheritdoc />
|
||||
public override void Invalidate()
|
||||
{
|
||||
this.State = new TextScrollPanelState();
|
||||
this._renderedCount = 0;
|
||||
this._lastItemOffsetFromBottom = 0;
|
||||
base.Invalidate();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -51,16 +54,59 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
return;
|
||||
}
|
||||
|
||||
// Move cursor to the bottom of the scroll area
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + props.Height - 1, props.X));
|
||||
int bottomRow = props.Y + props.Height - 1;
|
||||
|
||||
// Output only new items since last rendered
|
||||
for (int i = state.RenderedCount; i < props.Items.Count; i++)
|
||||
// Determine the first item to render. If we previously rendered items,
|
||||
// re-render the last one (it may have changed/grown) from its stored position.
|
||||
int startIndex = this._renderedCount > 0 ? this._renderedCount - 1 : 0;
|
||||
|
||||
if (this._renderedCount > 0 && this._lastItemOffsetFromBottom > 0)
|
||||
{
|
||||
// Reposition cursor to where the last rendered item began
|
||||
Console.Write(AnsiEscapes.MoveCursor(bottomRow - this._lastItemOffsetFromBottom, props.X));
|
||||
}
|
||||
else
|
||||
{
|
||||
// First render — position at the bottom of the scroll area
|
||||
Console.Write(AnsiEscapes.MoveCursor(bottomRow, props.X));
|
||||
}
|
||||
|
||||
// Render from startIndex onwards
|
||||
for (int i = startIndex; i < props.Items.Count; i++)
|
||||
{
|
||||
Console.Write(props.Items[i]);
|
||||
}
|
||||
|
||||
// Update state to track what we've rendered
|
||||
this.State = new TextScrollPanelState(props.Items.Count);
|
||||
// Calculate the offset from bottom for the start of the new last item
|
||||
int lastItemLines = CountLines(props.Items[^1]);
|
||||
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
|
||||
|
||||
// Update rendered count
|
||||
this._renderedCount = props.Items.Count;
|
||||
}
|
||||
|
||||
private static int CountLines(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 1;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
count--;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Caches the result of a mapping function and only recomputes when the input changes.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of the input value.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of the mapped output value.</typeparam>
|
||||
public class ConsoleReactiveMemo<TInput, TOutput>
|
||||
{
|
||||
private TInput? _previousInput;
|
||||
private TOutput? _cachedOutput;
|
||||
private bool _hasValue;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached output if <paramref name="input"/> equals the previously stored input;
|
||||
/// otherwise invokes <paramref name="mapper"/> to compute and cache a new output.
|
||||
/// </summary>
|
||||
/// <param name="input">The current input value.</param>
|
||||
/// <param name="mapper">A function that maps the input to an output value.</param>
|
||||
/// <returns>The cached or newly computed output.</returns>
|
||||
public TOutput Map(TInput input, Func<TInput, TOutput> mapper)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mapper);
|
||||
|
||||
if (!this._hasValue || !EqualityComparer<TInput>.Default.Equals(input, this._previousInput))
|
||||
{
|
||||
this._previousInput = input;
|
||||
this._cachedOutput = mapper(input);
|
||||
this._hasValue = true;
|
||||
}
|
||||
|
||||
return this._cachedOutput!;
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,11 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
await observer.OnResponseUpdateAsync(this._ux, update, this._agent, this._session).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
foreach (var observer in this._observers)
|
||||
|
||||
@@ -19,7 +19,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
private readonly ListSelection _listSelection = new();
|
||||
private readonly TextInput _textInput = new();
|
||||
private readonly TextScrollPanel _textScrollPanel = new();
|
||||
private readonly TextPanel _textPanel = new();
|
||||
private readonly TextPanel _queuedPanel = new();
|
||||
private readonly AgentStatus _agentStatus = new();
|
||||
private readonly AgentModeAndHelp _modeAndHelp = new();
|
||||
@@ -341,16 +340,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the text panel height for the last scroll item
|
||||
IReadOnlyList<string> lastItems = state.ScrollAreaContentItems.Count > 0
|
||||
? [state.ScrollAreaContentItems[^1]]
|
||||
: [];
|
||||
int textPanelHeight = TextPanel.CalculateHeight(lastItems);
|
||||
if (textPanelHeight > 0)
|
||||
{
|
||||
textPanelHeight++; // Extra line for spacing between text panel and rule
|
||||
}
|
||||
|
||||
// Calculate queued items panel height
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
|
||||
|
||||
@@ -444,7 +433,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
int modeAndHelpHeight = showStatusAndHelp ? AgentModeAndHelp.CalculateHeight(modeAndHelpProps) : 0;
|
||||
|
||||
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
|
||||
int nonScrollHeight = ruleHeight + textPanelHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
|
||||
int nonScrollHeight = ruleHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
|
||||
int scrollBottom = Math.Max(1, state.ConsoleHeight - nonScrollHeight);
|
||||
|
||||
// If scroll region changed or a clear is needed, reset everything
|
||||
@@ -455,52 +444,36 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._textScrollPanel.Reset();
|
||||
this._resizedSinceLastRender = false;
|
||||
|
||||
// Invalidate all children so they re-render even if props haven't changed
|
||||
this._rule.Invalidate();
|
||||
this._textScrollPanel.Invalidate();
|
||||
this._textPanel.Invalidate();
|
||||
this._queuedPanel.Invalidate();
|
||||
this._agentStatus.Invalidate();
|
||||
this._modeAndHelp.Invalidate();
|
||||
this._textInput.Invalidate();
|
||||
this._listSelection.Invalidate();
|
||||
|
||||
this._resizedSinceLastRender = false;
|
||||
}
|
||||
|
||||
this._scrollRegionBottom = scrollBottom;
|
||||
|
||||
System.Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
|
||||
|
||||
// Render text scroll panel in the scroll area (all items except the last)
|
||||
IReadOnlyList<string> scrollItems = state.ScrollAreaContentItems.Count > 1
|
||||
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList()
|
||||
: [];
|
||||
|
||||
// Render text scroll panel in the scroll area
|
||||
this._textScrollPanel.Props = new TextScrollPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = 1,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = scrollBottom,
|
||||
Items = scrollItems,
|
||||
Items = state.ScrollAreaContentItems,
|
||||
};
|
||||
this._textScrollPanel.Render();
|
||||
|
||||
// Render the text panel for the last (dynamic) item just below the scroll region
|
||||
this._textPanel.Props = new TextPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = scrollBottom + 1,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = textPanelHeight,
|
||||
Items = lastItems,
|
||||
};
|
||||
this._textPanel.Render();
|
||||
|
||||
// Render queued input items between text panel and agent status
|
||||
int queuedPanelY = scrollBottom + textPanelHeight + 1;
|
||||
// Render queued input items between scroll area and agent status
|
||||
int queuedPanelY = scrollBottom + 1;
|
||||
this._queuedPanel.Props = new TextPanelProps
|
||||
{
|
||||
X = 1,
|
||||
|
||||
@@ -24,6 +24,17 @@ public abstract class ConsoleObserver
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AgentResponseUpdate"/> in the response stream, regardless of
|
||||
/// whether it contains content. Override to inspect update-level metadata such as
|
||||
/// <see cref="AgentResponseUpdate.RawRepresentation"/> for provider-specific events.
|
||||
/// </summary>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="update">The streaming response update.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AIContent"/> item in the response stream.
|
||||
/// </summary>
|
||||
|
||||
+3
-3
@@ -5,17 +5,17 @@ using Microsoft.Extensions.AI;
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>AgentMode_*</c> tool calls, showing the target mode for Set operations.
|
||||
/// Formats <c>mode_*</c> tool calls, showing the target mode for Set operations.
|
||||
/// </summary>
|
||||
public sealed class ModeToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("AgentMode_", StringComparison.Ordinal);
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("mode_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"AgentMode_Set" => FormatStringArg(call, "mode"),
|
||||
"mode_set" => FormatStringArg(call, "mode"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Harness.Shared.Console.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// Detects and displays error/incomplete status from OpenAI Responses API streaming updates.
|
||||
/// Handles <see cref="StreamingResponseFailedUpdate"/> and <see cref="StreamingResponseIncompleteUpdate"/>
|
||||
/// which are not surfaced as <see cref="ErrorContent"/> by the chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note: <see cref="StreamingResponseErrorUpdate"/> is already handled by the SDK — it produces
|
||||
/// an <see cref="ErrorContent"/> which is displayed by <see cref="ErrorDisplayObserver"/>.
|
||||
/// This observer covers the cases where the SDK does not produce <see cref="ErrorContent"/>.
|
||||
/// </remarks>
|
||||
public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session)
|
||||
{
|
||||
// AgentResponseUpdate.RawRepresentation is the ChatResponseUpdate,
|
||||
// whose RawRepresentation is the underlying StreamingResponseUpdate.
|
||||
object? rawUpdate = (update.RawRepresentation as ChatResponseUpdate)?.RawRepresentation
|
||||
?? update.RawRepresentation;
|
||||
|
||||
switch (rawUpdate)
|
||||
{
|
||||
case StreamingResponseFailedUpdate failedUpdate:
|
||||
// Only display if the response has error details populated.
|
||||
// When error is null, a follow-up StreamingResponseErrorUpdate typically
|
||||
// carries the real error — the SDK surfaces that as ErrorContent,
|
||||
// which is displayed by ErrorDisplayObserver.
|
||||
if (failedUpdate.Response?.Error is { } error)
|
||||
{
|
||||
string errorMessage = error.Message ?? "Unknown error";
|
||||
string? errorCode = error.Code.ToString();
|
||||
string errorText = $"❌ Response failed: {errorMessage}";
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
{
|
||||
errorText += $" (code: {errorCode})";
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case StreamingResponseIncompleteUpdate incompleteUpdate:
|
||||
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
|
||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -3,19 +3,18 @@
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using System.Text;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace SampleApp;
|
||||
namespace Harness.Shared.Console.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// Displays web search activity in the scroll area. Shows search queries,
|
||||
/// page opens, and find-in-page actions as they stream in from the API.
|
||||
/// </summary>
|
||||
internal sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
|
||||
public sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private const int MaxQueryDisplayLength = 120;
|
||||
|
||||
+1
@@ -16,6 +16,7 @@
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -19,6 +19,7 @@ using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -107,6 +108,7 @@ await HarnessConsole.RunAgentAsync(
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
|
||||
+1
@@ -16,6 +16,7 @@
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+7
-5
@@ -16,6 +16,7 @@ using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -102,10 +103,7 @@ AIAgent parentAgent =
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
|
||||
DisableWebSearch = true,
|
||||
AIContextProviders =
|
||||
[
|
||||
new BackgroundAgentsProvider([webSearchAgent]),
|
||||
],
|
||||
BackgroundAgents = [webSearchAgent],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = parentInstructions,
|
||||
@@ -116,4 +114,8 @@ AIAgent parentAgent =
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
parentAgent,
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):",
|
||||
options: new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [new OpenAIResponsesErrorObserver(), .. HarnessConsoleOptions.BuildDefaultObservers()],
|
||||
});
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001;MEAI001;MCPEXP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates the Microsoft Agent Framework's MCP long-running task support.
|
||||
//
|
||||
// A small MCP server (hosted in this same executable when launched with "--server") exposes
|
||||
// a single task-supporting tool "AnalyzeDataset" that simulates ~15 seconds of work. The
|
||||
// client (default mode) connects to it over stdio via Microsoft.Agents.AI.Mcp's
|
||||
// McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync, hands the wrapped tools to a
|
||||
// ChatClientAgent, and exercises both invocation styles:
|
||||
// * RunAsync — blocks until the agent's final response is ready.
|
||||
// * RunStreamingAsync — yields response updates as the model produces them; the model
|
||||
// still waits for the tool's terminal result before it can begin
|
||||
// producing the final answer, so the perceived "pause" reflects
|
||||
// tool execution time, not stream-channel latency.
|
||||
//
|
||||
// In both cases the wrapper transparently:
|
||||
// 1. Calls tools/call with task augmentation (CallToolAsTaskAsync)
|
||||
// 2. Polls tasks/get until terminal (PollTaskUntilCompleteAsync)
|
||||
// 3. Fetches tasks/result and returns the final result to the function-calling loop
|
||||
//
|
||||
// No application-level loop or continuation tokens are required in either mode.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Mcp;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using OpenAI.Chat;
|
||||
|
||||
if (args.Length > 0 && args[0] == "--server")
|
||||
{
|
||||
await RunMcpServerAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
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-5.4-mini";
|
||||
|
||||
// Launch this same assembly as a stdio MCP server in a child process.
|
||||
var thisAssemblyPath = typeof(Program).Assembly.Location;
|
||||
await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport(new()
|
||||
{
|
||||
Name = "DatasetAnalyzer",
|
||||
Command = "dotnet",
|
||||
Arguments = [thisAssemblyPath, "--server"],
|
||||
}));
|
||||
|
||||
// Wrap each MCP tool with task-aware behavior. The wrapper inspects the server's
|
||||
// execution.taskSupport on each tool and, when it is Required, drives the task lifecycle
|
||||
// transparently within the agent's tool loop. Tools that don't require task semantics are
|
||||
// returned as-is and invoked inline.
|
||||
var taskOptions = new McpTaskOptions
|
||||
{
|
||||
DefaultTimeToLive = TimeSpan.FromMinutes(5),
|
||||
};
|
||||
var mcpTools = await mcpClient.ListAgentToolsWithTaskSupportAsync(taskOptions);
|
||||
|
||||
// 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())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(
|
||||
instructions: "You answer data-analysis questions by invoking the available tools. Always invoke a tool when one matches the request.",
|
||||
tools: [.. mcpTools.Cast<AITool>()]);
|
||||
|
||||
const string Prompt = "Analyze the dataset named 'sales-2025-q1' and summarize the findings.";
|
||||
|
||||
Console.WriteLine("=== Transparent long-running MCP task (RunAsync) ===");
|
||||
Console.WriteLine("Asking the agent to analyze a dataset; the tool takes ~15s to complete.");
|
||||
Console.WriteLine("RunAsync blocks while the wrapper polls the task to completion.");
|
||||
Console.WriteLine();
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var response = await agent.RunAsync(Prompt);
|
||||
stopwatch.Stop();
|
||||
|
||||
Console.WriteLine($"Agent response (after {stopwatch.Elapsed.TotalSeconds:F1}s):");
|
||||
Console.WriteLine(response.Text);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== Transparent long-running MCP task (RunStreamingAsync) ===");
|
||||
Console.WriteLine("Same request via the streaming API. Updates only begin to arrive after the");
|
||||
Console.WriteLine("tool's task reaches the Completed state, since the model needs the tool result");
|
||||
Console.WriteLine("before it can produce its final answer.");
|
||||
Console.WriteLine();
|
||||
|
||||
stopwatch.Restart();
|
||||
await foreach (var update in agent.RunStreamingAsync(Prompt))
|
||||
{
|
||||
Console.Write(update.Text);
|
||||
}
|
||||
stopwatch.Stop();
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"(Streaming completed after {stopwatch.Elapsed.TotalSeconds:F1}s.)");
|
||||
|
||||
// --- Server mode (launched as a child process via --server) ---------------------------------
|
||||
static async Task RunMcpServerAsync()
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
|
||||
// Critical for stdio transport: any provider that writes to stdout will corrupt the
|
||||
// JSON-RPC channel. Clear all providers; the MCP SDK routes its own diagnostics
|
||||
// appropriately.
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
|
||||
|
||||
builder.Services.AddMcpServer(o =>
|
||||
{
|
||||
o.TaskStore = new InMemoryMcpTaskStore();
|
||||
o.ServerInfo = new Implementation { Name = "DatasetAnalyzer", Version = "1.0.0" };
|
||||
})
|
||||
.WithStdioServerTransport()
|
||||
.WithTools<DatasetAnalysisTools>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
|
||||
#pragma warning disable CA1812 // Discovered by MCP SDK via [McpServerToolType] attribute
|
||||
[McpServerToolType]
|
||||
internal sealed class DatasetAnalysisTools
|
||||
#pragma warning restore CA1812
|
||||
{
|
||||
[McpServerTool(Name = "AnalyzeDataset", TaskSupport = ToolTaskSupport.Required)]
|
||||
[Description("Analyze a tabular dataset and return summary statistics. This tool simulates a long-running analytic job (~15 seconds).")]
|
||||
public static async Task<string> AnalyzeDatasetAsync(
|
||||
[Description("The dataset identifier, e.g. 'sales-2025-q1'.")] string datasetName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return $"Findings for '{datasetName}': 12,403 rows; avg revenue $48,712; 3 anomalies detected in week 7; outliers concentrated in EMEA region.";
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# Agent with MCP long-running task (transparent polling)
|
||||
|
||||
This sample demonstrates Microsoft Agent Framework's MCP long-running task support: an agent invokes an MCP tool whose execution takes too long for a single request/response cycle, and the framework polls it to completion behind the function-calling loop. From the agent's perspective the tool simply returns its result.
|
||||
|
||||
## What this sample shows
|
||||
|
||||
- Using `McpClient.ListAgentToolsWithTaskSupportAsync(...)` (in `Microsoft.Agents.AI.Mcp`) to wrap MCP tools with task-aware behavior.
|
||||
- Configuring `McpTaskOptions.DefaultTimeToLive` to bound the server-side task.
|
||||
- Hosting a small MCP server (in this same executable, launched with `--server`) that advertises `execution.taskSupport=required` on a tool that sleeps for ~15 seconds.
|
||||
- No application-level polling, continuation tokens, or `AllowBackgroundResponses` flag are required.
|
||||
|
||||
The decorator drives the lifecycle internally:
|
||||
|
||||
1. `tools/call` augmented with task metadata (`CallToolAsTaskAsync`)
|
||||
2. `tasks/get` polled until terminal (`PollTaskUntilCompleteAsync`)
|
||||
3. `tasks/result` retrieved (`GetTaskResultAsync`) and returned to the function-calling loop
|
||||
|
||||
The sample exercises both invocation styles against the same wrapper:
|
||||
|
||||
- `agent.RunAsync(...)` blocks until the tool completes (~15 seconds in this sample) and returns the final response.
|
||||
- `agent.RunStreamingAsync(...)` returns immediately and yields `AgentResponseUpdate` chunks as the model emits them; in this scenario the model only begins streaming its answer once the wrapped tool's task reaches the `Completed` state, so the perceived "pause" before tokens arrive reflects tool execution time, not stream-channel latency.
|
||||
|
||||
# Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure OpenAI service endpoint and a chat-completions deployment
|
||||
- Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # optional; defaults to gpt-5.4-mini
|
||||
```
|
||||
|
||||
# Running
|
||||
|
||||
```powershell
|
||||
cd Agent_MCP_LongRunningTask_Client
|
||||
dotnet run
|
||||
```
|
||||
|
||||
You should see output similar to:
|
||||
|
||||
```
|
||||
=== Transparent long-running MCP task (RunAsync) ===
|
||||
Asking the agent to analyze a dataset; the tool takes ~15s to complete.
|
||||
RunAsync blocks while the wrapper polls the task to completion.
|
||||
|
||||
Agent response (after 15.4s):
|
||||
The 'sales-2025-q1' dataset contains 12,403 rows ...
|
||||
|
||||
=== Transparent long-running MCP task (RunStreamingAsync) ===
|
||||
Same request via the streaming API. Updates only begin to arrive after the
|
||||
tool's task reaches the Completed state, since the model needs the tool result
|
||||
before it can produce its final answer.
|
||||
|
||||
The 'sales-2025-q1' dataset contains 12,403 rows ...
|
||||
(Streaming completed after 15.7s.)
|
||||
```
|
||||
@@ -22,6 +22,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|
||||
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|
||||
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|
||||
|[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAIW001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample ports the Python Magentic orchestration sample to .NET.
|
||||
// A Magentic workflow coordinates a researcher and a coder, streams orchestration
|
||||
// events as the plan evolves, and prints the final conversation transcript.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace WorkflowMagenticOrchestrationSample;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates Magentic orchestration with a researcher, a coder, and an LLM manager.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
/// - An Azure AI Foundry project endpoint and model deployment must be configured.
|
||||
/// - Run <c>az login</c> before executing the sample.
|
||||
/// </remarks>
|
||||
public static class Program
|
||||
{
|
||||
private const string TaskPrompt =
|
||||
"I am preparing a report on the energy efficiency of different machine learning model architectures. " +
|
||||
"Compare the estimated training and inference energy consumption of ResNet-50, BERT-base, and GPT-2 " +
|
||||
"on standard datasets (e.g., ImageNet for ResNet, GLUE for BERT, WebText for GPT-2). " +
|
||||
"Then, estimate the CO2 emissions associated with each, assuming training on an Azure Standard_NC6s_v3 " +
|
||||
"VM for 24 hours. Provide tables for clarity, and recommend the most energy-efficient model " +
|
||||
"per task type (image classification, text classification, and text generation).";
|
||||
|
||||
private static async Task Main()
|
||||
{
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
|
||||
// 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.
|
||||
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
AIAgent researcherAgent = projectClient.AsAIAgent(
|
||||
deploymentName,
|
||||
name: "ResearcherAgent",
|
||||
description: "Specialist in research and information gathering.",
|
||||
instructions: "You are a researcher. Find relevant information without doing additional computation or quantitative analysis.");
|
||||
|
||||
AIAgent coderAgent = projectClient.AsAIAgent(
|
||||
deploymentName,
|
||||
name: "CoderAgent",
|
||||
description: "A helpful assistant that writes and executes code to analyze data.",
|
||||
instructions: "You solve quantitative questions by writing and running code. Show the analysis and the computation process clearly.",
|
||||
tools: [new HostedCodeInterpreterTool()]);
|
||||
|
||||
AIAgent managerAgent = projectClient.AsAIAgent(
|
||||
deploymentName,
|
||||
name: "MagenticManager",
|
||||
description: "Orchestrator that coordinates the research and coding workflow.",
|
||||
instructions: "You coordinate the team to complete complex tasks efficiently.");
|
||||
|
||||
Workflow workflow = new MagenticWorkflowBuilder(managerAgent)
|
||||
.AddParticipants([researcherAgent, coderAgent])
|
||||
.WithName("Magentic Orchestration Workflow")
|
||||
.WithDescription("Coordinates a researcher and coder to solve a complex analytical task.")
|
||||
.RequirePlanSignoff(false)
|
||||
.WithMaxRounds(10)
|
||||
.WithMaxStalls(3)
|
||||
.WithMaxResets(2)
|
||||
.Build();
|
||||
|
||||
Console.WriteLine("Building Magentic workflow...");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Task: {TaskPrompt}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Starting workflow execution...");
|
||||
|
||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(
|
||||
workflow,
|
||||
new List<ChatMessage> { new(ChatRole.User, TaskPrompt) });
|
||||
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
|
||||
string? lastResponseId = null;
|
||||
WorkflowOutputEvent? finalOutput = null;
|
||||
|
||||
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
|
||||
{
|
||||
switch (workflowEvent)
|
||||
{
|
||||
case AgentResponseUpdateEvent updateEvent:
|
||||
WriteStreamingUpdate(updateEvent, ref lastResponseId);
|
||||
break;
|
||||
|
||||
case MagenticPlanCreatedEvent planCreated:
|
||||
WriteMagenticMessage("Initial Plan", planCreated.FullTaskLedger.Text);
|
||||
PauseIfInteractive();
|
||||
break;
|
||||
|
||||
case MagenticReplannedEvent replanned:
|
||||
WriteMagenticMessage("Replanned", replanned.FullTaskLedger.Text);
|
||||
PauseIfInteractive();
|
||||
break;
|
||||
|
||||
case MagenticProgressLedgerUpdatedEvent progressUpdated:
|
||||
WriteMagenticMessage("Progress Ledger", FormatProgressLedger(progressUpdated.ProgressLedger));
|
||||
PauseIfInteractive();
|
||||
break;
|
||||
|
||||
case WorkflowOutputEvent outputEvent when outputEvent.Is<List<ChatMessage>>():
|
||||
finalOutput = outputEvent;
|
||||
break;
|
||||
|
||||
case WorkflowErrorEvent workflowError:
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine(workflowError.Exception?.ToString() ?? "Unknown workflow error occurred.");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case ExecutorFailedEvent executorFailed:
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine($"Executor '{executorFailed.ExecutorId}' failed with {(executorFailed.Data is null ? "unknown error" : $"exception {executorFailed.Data}")}.");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalOutput?.As<List<ChatMessage>>() is { } transcript)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(new string('=', 80));
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Final Conversation Transcript:");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (ChatMessage message in transcript)
|
||||
{
|
||||
Console.WriteLine($"{message.AuthorName ?? message.Role.ToString()}: {message.Text}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteStreamingUpdate(AgentResponseUpdateEvent updateEvent, ref string? lastResponseId)
|
||||
{
|
||||
string responseId = updateEvent.Update.ResponseId ?? updateEvent.Update.MessageId ?? updateEvent.ExecutorId;
|
||||
if (!string.Equals(responseId, lastResponseId, StringComparison.Ordinal))
|
||||
{
|
||||
if (lastResponseId is not null)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.Write($"- {updateEvent.ExecutorId}: ");
|
||||
lastResponseId = responseId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(updateEvent.Update.Text))
|
||||
{
|
||||
Console.Write(updateEvent.Update.Text);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteMagenticMessage(string title, string? content)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"[Magentic {title}]");
|
||||
Console.WriteLine(content);
|
||||
}
|
||||
|
||||
private static string FormatProgressLedger(MagenticProgressLedger ledger) =>
|
||||
string.Join(Environment.NewLine,
|
||||
$"Request satisfied: {ledger.IsRequestSatisfied}",
|
||||
$"In loop: {ledger.IsInLoop}",
|
||||
$"Making progress: {ledger.IsProgressBeingMade}",
|
||||
$"Next speaker: {ledger.NextSpeaker}",
|
||||
$"Instruction: {ledger.InstructionOrQuestion}");
|
||||
|
||||
private static void PauseIfInteractive()
|
||||
{
|
||||
if (Console.IsInputRedirected || Console.IsOutputRedirected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Write("Press Enter to continue...");
|
||||
Console.ReadLine();
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Magentic Orchestration Sample
|
||||
|
||||
This sample showcases the Magentic Orchestration Pattern in .NET, setting up a team with three roles:
|
||||
|
||||
- **ResearcherAgent** gathers factual background information.
|
||||
- **CoderAgent** uses `HostedCodeInterpreterTool` for quantitative analysis.
|
||||
- **MagenticManager** plans the work, tracks progress, and decides who should act next.
|
||||
|
||||
## What This Sample Demonstrates
|
||||
|
||||
- Building a Magentic workflow with `MagenticWorkflowBuilder`
|
||||
- Combining standard responses-based agents with a code interpreter-enabled participant
|
||||
- Streaming orchestration events such as the initial plan, replans, and progress-ledger updates
|
||||
- Printing the final multi-agent conversation transcript
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` set to your Azure AI Foundry project endpoint
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` set to your model deployment name (defaults to `gpt-5.4-mini`)
|
||||
- `az login` completed before running the sample
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
The sample prints:
|
||||
|
||||
1. The original task prompt
|
||||
2. Streamed updates from the participating agents
|
||||
3. Magentic plan and progress-ledger events as the workflow coordinates the team
|
||||
4. The final conversation transcript returned by the workflow
|
||||
|
||||
## Related Samples
|
||||
|
||||
- [Handoff Orchestration](../Handoff) - another multi-agent orchestration pattern in .NET workflows
|
||||
- [Python Magentic workflow sample](../../../../../python/samples/03-workflows/orchestrations/magentic.py) - the source scenario that this sample ports
|
||||
@@ -62,3 +62,4 @@ Once completed, please proceed to the other samples listed below.
|
||||
| Sample | Concepts |
|
||||
|--------|----------|
|
||||
| [Handoff Orchestration](./Orchestration/Handoff) | Introduces the Handoff Orchestration pattern |
|
||||
| [Magentic Orchestration](./Orchestration/Magentic) | Coordinates multiple agents with a Magentic manager, streamed plan events, and a final transcript |
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AGENT_NAME=hosted-agent-skills
|
||||
SKILL_NAMES=support-style,escalation-policy
|
||||
# Set to true to provision sample skills to Foundry on startup (first-run convenience).
|
||||
# In production, skills are provisioned externally — leave this unset or false.
|
||||
PROVISION_SAMPLE_SKILLS=true
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
# When running outside the Foundry platform the platform-injected isolation keys are absent.
|
||||
# These two variables provide fallback values for local Docker debugging only.
|
||||
HOSTED_USER_ISOLATION_KEY=local-dev-user
|
||||
HOSTED_CHAT_ISOLATION_KEY=local-dev-chat
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
|
||||
#
|
||||
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
|
||||
# which only succeeds when the project references its dependencies via PackageReference (see the
|
||||
# commented-out section in HostedAgentSkills.csproj). Contributors building from the
|
||||
# agent-framework repository source must use Dockerfile.contributor instead because
|
||||
# ProjectReference dependencies live outside this folder and cannot be restored from inside
|
||||
# this build context.
|
||||
#
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedAgentSkills.dll"]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-agent-skills .
|
||||
# docker run --rm -p 8088:8088 \
|
||||
# -e AGENT_NAME=hosted-agent-skills \
|
||||
# -e HOSTED_USER_ISOLATION_KEY=alice \
|
||||
# -e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \
|
||||
# --env-file .env hosted-agent-skills
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedAgentSkills.dll"]
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedAgentSkills</RootNamespace>
|
||||
<AssemblyName>HostedAgentSkills</AssemblyName>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001;AAIP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
<!-- Include the skills/ directory in the publish output so the sample can provision them -->
|
||||
<ItemGroup>
|
||||
<None Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted-AgentSkills
|
||||
//
|
||||
// Demonstrates how to host an agent that loads its behavioral guidelines from Foundry Skills at
|
||||
// startup. Skills are authored as SKILL.md files, uploaded to Foundry via the Skills REST API,
|
||||
// and downloaded by the agent on boot so guideline updates ship without code changes.
|
||||
//
|
||||
// The agent uses AgentSkillsProvider from the Agent Framework which implements the progressive
|
||||
// disclosure pattern from the Agent Skills specification (https://agentskills.io/):
|
||||
// 1. Advertise — skill names and descriptions are injected into the system prompt.
|
||||
// 2. Load — the model calls load_skill to retrieve the full SKILL.md body on demand.
|
||||
//
|
||||
// IMPORTANT: In production, skill provisioning (uploading SKILL.md files to Foundry) is an
|
||||
// external concern — it is NOT the hosted agent's responsibility. The provisioning helper below
|
||||
// is included for sample convenience only, so the sample is self-contained and runnable without
|
||||
// a separate setup step. A real deployment pipeline would provision skills separately (e.g., via
|
||||
// a CI/CD step, a CLI script, or a management portal).
|
||||
|
||||
#pragma warning disable AAIP001 // ProjectAgentSkills is experimental
|
||||
|
||||
using System.ClientModel;
|
||||
using System.IO.Compression;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
string skillNames = Environment.GetEnvironmentVariable("SKILL_NAMES")
|
||||
?? throw new InvalidOperationException("SKILL_NAMES is not set. Provide a comma-separated list of skill names (e.g., support-style,escalation-policy).");
|
||||
|
||||
string[] requestedSkills = skillNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (requestedSkills.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("SKILL_NAMES must list at least one skill name.");
|
||||
}
|
||||
|
||||
// Validate skill names to prevent path traversal.
|
||||
foreach (string name in requestedSkills)
|
||||
{
|
||||
if (name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Invalid skill name '{name}': skill names must not contain path separators or dots.");
|
||||
}
|
||||
}
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
AIProjectClient projectClient = new(new Uri(endpoint), credential);
|
||||
ProjectAgentSkills skillsClient = projectClient.AgentAdministrationClient.GetAgentSkills();
|
||||
|
||||
// ── Provision skills (sample convenience only — NOT a production pattern) ─────
|
||||
// In production, skills are provisioned externally (e.g., via CI/CD or a management script).
|
||||
// This helper ensures the sample's SKILL.md files exist in Foundry so the sample is runnable
|
||||
// out of the box without a separate setup step. Set PROVISION_SAMPLE_SKILLS=true to enable.
|
||||
string sourceSkillsDir = Path.Combine(AppContext.BaseDirectory, "skills");
|
||||
bool provisionEnabled = string.Equals(
|
||||
Environment.GetEnvironmentVariable("PROVISION_SAMPLE_SKILLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (provisionEnabled && Directory.Exists(sourceSkillsDir))
|
||||
{
|
||||
await EnsureSkillsProvisionedAsync(skillsClient, sourceSkillsDir, requestedSkills);
|
||||
}
|
||||
|
||||
// ── Download skills from Foundry ─────────────────────────────────────────────
|
||||
// Pull the latest copy of each skill from Foundry into a runtime-only folder.
|
||||
// This directory is recreated on every startup so the agent always picks up
|
||||
// the latest version of each skill.
|
||||
string downloadedSkillsDir = Path.Combine(AppContext.BaseDirectory, "downloaded_skills");
|
||||
await DownloadSkillsAsync(skillsClient, requestedSkills, downloadedSkillsDir);
|
||||
|
||||
// ── Wire skills into the agent ───────────────────────────────────────────────
|
||||
// AgentSkillsProvider implements progressive disclosure: skill names and descriptions
|
||||
// are advertised in the system prompt (~100 tokens per skill), and the full SKILL.md
|
||||
// body is loaded on demand when the model calls the load_skill tool.
|
||||
AgentSkillsProvider skillsProvider = new(downloadedSkillsDir);
|
||||
|
||||
ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-agent-skills",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deploymentName,
|
||||
Instructions = "You are a customer-support assistant for Contoso Outdoors.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider]
|
||||
});
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Downloads each named skill from Foundry and extracts the ZIP archive into a
|
||||
// separate subdirectory under the target directory.
|
||||
static async Task DownloadSkillsAsync(ProjectAgentSkills skillsClient, string[] skillNames, string targetDir)
|
||||
{
|
||||
if (Directory.Exists(targetDir))
|
||||
{
|
||||
Directory.Delete(targetDir, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
foreach (string name in skillNames)
|
||||
{
|
||||
Console.WriteLine($"Downloading skill '{name}' from Foundry...");
|
||||
BinaryData zipData = await skillsClient.DownloadSkillAsync(name);
|
||||
|
||||
string skillDir = Path.Combine(targetDir, name);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
|
||||
using var zipStream = zipData.ToStream();
|
||||
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
|
||||
SafeExtractZip(archive, skillDir);
|
||||
|
||||
if (!File.Exists(Path.Combine(skillDir, "SKILL.md")))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Downloaded archive for '{name}' did not contain a SKILL.md at the root.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extracts a ZIP archive into a destination directory, rejecting entries that would
|
||||
// escape the target path (zip-slip guard).
|
||||
static void SafeExtractZip(ZipArchive archive, string destinationDir)
|
||||
{
|
||||
string destRoot = Path.GetFullPath(destinationDir);
|
||||
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
|
||||
? destRoot
|
||||
: destRoot + Path.DirectorySeparatorChar;
|
||||
|
||||
// Use ordinal comparison on Unix (case-sensitive FS) and ordinal-ignore-case on Windows.
|
||||
var comparison = OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
foreach (ZipArchiveEntry entry in archive.Entries)
|
||||
{
|
||||
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
|
||||
if (!entryPath.StartsWith(destRootWithSep, comparison)
|
||||
&& !string.Equals(entryPath, destRoot, comparison))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
{
|
||||
// Directory entry — ensure it exists.
|
||||
Directory.CreateDirectory(entryPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
|
||||
entry.ExtractToFile(entryPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures each requested skill is provisioned in Foundry. For each skill name, checks whether
|
||||
// the skill exists and uploads it from the local source directory if it does not.
|
||||
//
|
||||
// This is a sample convenience helper — in production, skill provisioning is an external concern.
|
||||
static async Task EnsureSkillsProvisionedAsync(ProjectAgentSkills skillsClient, string sourceDir, string[] skillNames)
|
||||
{
|
||||
foreach (string name in skillNames)
|
||||
{
|
||||
string skillPath = Path.Combine(sourceDir, name);
|
||||
if (!Directory.Exists(skillPath) || !File.Exists(Path.Combine(skillPath, "SKILL.md")))
|
||||
{
|
||||
continue; // No local source for this skill — skip provisioning.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await skillsClient.GetSkillAsync(name);
|
||||
Console.WriteLine($"Skill '{name}' already exists in Foundry.");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
Console.WriteLine($"Provisioning skill '{name}' from {skillPath}...");
|
||||
AgentsSkill imported = await skillsClient.CreateSkillFromPackageAsync(skillPath);
|
||||
Console.WriteLine($" Imported skill '{imported.Name}' (id={imported.SkillId}, has_blob={imported.HasBlob}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
An [Agent Framework](https://github.com/microsoft/agent-framework) agent that loads its behavioral guidelines from [**Foundry Skills**](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/skills) at startup, hosted using the **Responses protocol**. Skills are authored once as `SKILL.md` files, uploaded to your Foundry project through the Skills REST API, and downloaded by the agent on boot so updates ship without code changes.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Authoring skills
|
||||
|
||||
Each skill is a Markdown file with a YAML front matter block. This sample ships two source skills under [`skills/`](skills/):
|
||||
|
||||
| Skill | Purpose |
|
||||
|---|---|
|
||||
| [`support-style`](skills/support-style/SKILL.md) | Voice, formatting, and signature rules for Contoso Outdoors support replies. |
|
||||
| [`escalation-policy`](skills/escalation-policy/SKILL.md) | When and how to escalate a customer ticket. |
|
||||
|
||||
Each `SKILL.md` includes a unique `*-CANARY-*` token that the model is asked to echo, so you can prove the skill was loaded from Foundry (not hallucinated) by checking the response.
|
||||
|
||||
> The `name` and `description` values in the YAML front matter must be **unquoted** — quoting them causes the Skills REST API to return HTTP 500 on import.
|
||||
|
||||
### Uploading skills
|
||||
|
||||
The sample includes a convenience provisioning step that checks whether each skill exists in Foundry and uploads it if not, gated behind the `PROVISION_SAMPLE_SKILLS=true` env var. **In production, skill provisioning is an external concern** — it is NOT the hosted agent's responsibility. A real deployment pipeline would provision skills separately (e.g., via a CI/CD step, a CLI script, or a management portal).
|
||||
|
||||
The provisioning uses `ProjectAgentSkills.CreateSkillFromPackageAsync(directoryPath)` from the `Azure.AI.Projects.Agents` SDK. The method packages the `SKILL.md` file as a ZIP and uploads it to Foundry.
|
||||
|
||||
### Downloading skills at agent startup
|
||||
|
||||
[`Program.cs`](Program.cs) reads the comma-separated `SKILL_NAMES` env var and for each skill name downloads the ZIP archive from Foundry via `ProjectAgentSkills.DownloadSkillAsync(name)`, then unpacks it into a **separate runtime directory** at `downloaded_skills/<name>/` (kept distinct from the static `skills/` source folder).
|
||||
|
||||
An [`AgentSkillsProvider`](../../../../../src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs) is then built over `downloaded_skills/` and attached to the agent as a context provider. The provider follows the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern:
|
||||
|
||||
1. **Advertise** — skill names and descriptions are injected into the system prompt at session start (~100 tokens per skill).
|
||||
2. **Load** — the model calls the `load_skill` tool when it decides a skill is relevant to the user's turn, and the full `SKILL.md` body is returned.
|
||||
|
||||
This means the model only pays the token cost for a skill's full body when it actually needs it, and updating a skill in Foundry + restarting the agent is enough to pick up the change — no code redeploy required.
|
||||
|
||||
> **Note:** This sample supports instruction-only and resource-based skills. If your downloaded skills contain scripts, add a script runner when constructing the `AgentSkillsProvider`.
|
||||
|
||||
### Agent Hosting
|
||||
|
||||
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the Responses API hosting layer (`AddFoundryResponses` / `MapFoundryResponses`).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
### Required RBAC
|
||||
|
||||
Your identity (or the Managed Identity running the container in production) needs **Azure AI User** on the Foundry project scope. This single role covers both authoring skills and downloading them.
|
||||
|
||||
## Running the Agent Host
|
||||
|
||||
Set the required environment variables and run the sample with `dotnet run`:
|
||||
|
||||
```bash
|
||||
export AZURE_AI_PROJECT_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o"
|
||||
export SKILL_NAMES="support-style,escalation-policy"
|
||||
export PROVISION_SAMPLE_SKILLS="true" # First run only — provisions skills to Foundry
|
||||
```
|
||||
|
||||
Or in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:SKILL_NAMES="support-style,escalation-policy"
|
||||
$env:PROVISION_SAMPLE_SKILLS="true" # First run only — provisions skills to Foundry
|
||||
```
|
||||
|
||||
You can also place these in a `.env` file next to `Program.cs` — see [`.env.example`](.env.example).
|
||||
|
||||
On startup you should see:
|
||||
|
||||
```text
|
||||
Skill 'support-style' already exists in Foundry.
|
||||
Skill 'escalation-policy' already exists in Foundry.
|
||||
Downloading skill 'support-style' from Foundry...
|
||||
Downloading skill 'escalation-policy' from Foundry...
|
||||
```
|
||||
|
||||
The downloaded `SKILL.md` files land under `downloaded_skills/<name>/SKILL.md` next to the published output. This directory is recreated from scratch on every run, so deleting it manually is never necessary.
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
> Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hi, I am Alex. I just want to confirm I can return my tent within 30 days."}'
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "I want a $750 refund on Order #A-1042 right now or I am calling my lawyer."}'
|
||||
```
|
||||
|
||||
| Prompt mentions | Skill that should drive the response |
|
||||
|---|---|
|
||||
| Routine return / shipping / care question | Model loads `support-style` (canary `STYLE-CANARY-3318`) — no escalation. |
|
||||
| Injury, legal threat, press, or refund > $500 | Model loads `escalation-policy` (canary `ESC-CANARY-7742`) **and** `support-style`. |
|
||||
|
||||
Because skills are loaded on demand, the canary token in a response also proves the model actually invoked `load_skill` for the matching skill (not just saw its name in the advertised list).
|
||||
|
||||
## Deploying the Agent to Foundry
|
||||
|
||||
When deploying to Foundry, make sure `SKILL_NAMES` is set in your `azd` environment so it gets injected into the hosted container per [`agent.manifest.yaml`](agent.manifest.yaml):
|
||||
|
||||
```bash
|
||||
azd env set SKILL_NAMES "support-style,escalation-policy"
|
||||
```
|
||||
|
||||
The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup.
|
||||
|
||||
> The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The provisioning step must have been run against the same Foundry project before the agent can download the skills.
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-agent-skills
|
||||
displayName: "Hosted Agent Skills"
|
||||
|
||||
description: >
|
||||
An Agent Framework agent that downloads its behavioral guidelines from the Foundry
|
||||
Skills REST API at startup, demonstrating how to decouple behavioral guidelines
|
||||
(tone, escalation policy, etc.) from agent code using AgentSkillsProvider.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Agent Framework
|
||||
- Agent Skills
|
||||
- Foundry Skills
|
||||
|
||||
template:
|
||||
name: hosted-agent-skills
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: SKILL_NAMES
|
||||
value: "{{SKILL_NAMES}}"
|
||||
parameters:
|
||||
properties:
|
||||
- name: SKILL_NAMES
|
||||
secret: false
|
||||
description: Comma-separated list of Foundry skill names to download at startup (e.g., support-style,escalation-policy)
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
@@ -0,0 +1,14 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-agent-skills
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
- name: SKILL_NAMES
|
||||
value: ${SKILL_NAMES}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
#requires -Version 7
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Local smoke test for the Hosted-AgentSkills sample.
|
||||
.DESCRIPTION
|
||||
Publishes the sample, builds the contributor Docker image, runs the container, drives
|
||||
two conversations via curl invocations, and asserts that the agent loaded the correct
|
||||
Foundry Skill for each prompt (verified via canary tokens in the response).
|
||||
Exits non-zero on failure.
|
||||
|
||||
Prerequisites:
|
||||
- Docker
|
||||
- az login (token is fetched from the host)
|
||||
- .env populated with AZURE_AI_PROJECT_ENDPOINT and model deployment
|
||||
- Skills provisioned to Foundry (set PROVISION_SAMPLE_SKILLS=true on first run)
|
||||
.NOTES
|
||||
This script is for local Docker debugging only. The Foundry platform supplies the
|
||||
isolation keys for every inbound request in production and the dev fallback used here
|
||||
must not be enabled in production deployments.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Port = 8088,
|
||||
[string]$ImageName = 'hosted-agent-skills-smoke',
|
||||
[string]$ContainerName = 'hosted-agent-skills-smoke'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location -Path $PSScriptRoot/..
|
||||
|
||||
if (-not (Test-Path .env)) {
|
||||
throw '.env not found. Copy .env.example to .env and fill in AZURE_AI_PROJECT_ENDPOINT.'
|
||||
}
|
||||
|
||||
Write-Host '==> Publishing sample for linux-musl-x64 ...'
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out --tl:off | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
|
||||
|
||||
Write-Host '==> Building docker image ...'
|
||||
docker build -f Dockerfile.contributor -t $ImageName . | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'docker build failed.' }
|
||||
|
||||
Write-Host '==> Fetching bearer token ...'
|
||||
$bearer = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv
|
||||
if (-not $bearer) { throw 'Failed to obtain bearer token. Run az login.' }
|
||||
|
||||
function Start-Container {
|
||||
docker rm -f $ContainerName 2>$null | Out-Null
|
||||
docker run -d --name $ContainerName -p ${Port}:8088 `
|
||||
-e AGENT_NAME=hosted-agent-skills `
|
||||
-e AZURE_BEARER_TOKEN=$bearer `
|
||||
-e HOSTED_USER_ISOLATION_KEY=smoke-user `
|
||||
-e HOSTED_CHAT_ISOLATION_KEY=smoke-chat-1 `
|
||||
--env-file .env `
|
||||
$ImageName | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "docker run failed." }
|
||||
# Wait for the server to start and download skills from Foundry.
|
||||
Write-Host ' Waiting for startup (skill download + server ready) ...'
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
|
||||
function Invoke-Agent([string]$Prompt, [string]$PreviousResponseId = $null) {
|
||||
$body = @{ input = $Prompt; model = 'hosted-agent-skills' }
|
||||
if ($PreviousResponseId) { $body['previous_response_id'] = $PreviousResponseId }
|
||||
$json = $body | ConvertTo-Json -Compress
|
||||
$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Body $json
|
||||
return $resp
|
||||
}
|
||||
|
||||
function Get-ResponseText($response) {
|
||||
return ($response.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
|
||||
}
|
||||
|
||||
function Assert-Contains([string]$Haystack, [string]$Needle, [string]$Label) {
|
||||
if ($Haystack -notmatch [regex]::Escape($Needle)) {
|
||||
throw "FAILED [$Label]: expected response to contain '$Needle' but got: $Haystack"
|
||||
}
|
||||
Write-Host "PASS [$Label]: response contains '$Needle'."
|
||||
}
|
||||
|
||||
try {
|
||||
Start-Container
|
||||
|
||||
Write-Host '==> Test 1: Routine support question -> support-style skill ...'
|
||||
$r1 = Invoke-Agent -Prompt 'Hi, I am Alex. I just want to confirm I can return my tent within 30 days.'
|
||||
$text1 = Get-ResponseText $r1
|
||||
Assert-Contains $text1 'STYLE-CANARY-3318' 'routine question: support-style canary'
|
||||
|
||||
Write-Host '==> Test 2: Escalation trigger -> escalation-policy skill ...'
|
||||
$r2 = Invoke-Agent -Prompt 'I want a $750 refund on Order #A-1042 right now or I am calling my lawyer.'
|
||||
$text2 = Get-ResponseText $r2
|
||||
Assert-Contains $text2 'ESC-CANARY-7742' 'escalation trigger: escalation-policy canary'
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '==> All smoke assertions passed.'
|
||||
}
|
||||
finally {
|
||||
docker rm -f $ContainerName 2>$null | Out-Null
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: escalation-policy
|
||||
description: When and how to escalate Contoso Outdoors customer-support tickets.
|
||||
---
|
||||
|
||||
# Contoso Outdoors Escalation Policy
|
||||
|
||||
You must follow this escalation policy on every conversation.
|
||||
|
||||
## Escalate immediately when the customer
|
||||
|
||||
- Reports an injury, allergic reaction, or other safety incident.
|
||||
- Mentions legal action, regulators, or the press.
|
||||
- Has waited more than 14 days for a refund that was already approved.
|
||||
- Requests a refund larger than $500.
|
||||
|
||||
## How to escalate
|
||||
|
||||
1. Acknowledge the issue in one sentence.
|
||||
2. Tell the customer you are escalating to a senior specialist.
|
||||
3. Provide the escalation reference `ESC-CANARY-7742` and the SLA: a senior
|
||||
specialist will reply within 1 business day.
|
||||
4. Do not promise a specific outcome (refund, replacement, compensation) on
|
||||
escalated tickets — only the senior specialist can commit to one.
|
||||
|
||||
## Do not escalate
|
||||
|
||||
- Routine returns within the standard 30-day window.
|
||||
- Shipping status questions.
|
||||
- Product care and usage questions.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: support-style
|
||||
description: Contoso Outdoors customer-support tone and formatting guidelines.
|
||||
---
|
||||
|
||||
# Contoso Outdoors Support Style
|
||||
|
||||
You are speaking on behalf of Contoso Outdoors customer support.
|
||||
|
||||
## Voice
|
||||
|
||||
- Warm, concise, and confident — never apologetic in a hand-wringing way.
|
||||
- Use the customer's name when it is known.
|
||||
- Sign every response with `— Contoso Outdoors Support`.
|
||||
|
||||
## Formatting
|
||||
|
||||
- Keep replies to 1–3 short paragraphs unless the customer asks for detail.
|
||||
- Use bullet lists only when enumerating concrete steps or options.
|
||||
- Always reference order numbers as `Order #<id>` (e.g. `Order #A-1042`).
|
||||
|
||||
## Canary
|
||||
|
||||
To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a
|
||||
separate line at the bottom of every response, prefixed with `# `.
|
||||
+3
-3
@@ -27,9 +27,9 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+3
-3
@@ -26,9 +26,9 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+5
-5
@@ -32,11 +32,11 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.6.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
+4
-4
@@ -27,10 +27,10 @@
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.6.1" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
|
||||
@@ -474,6 +474,7 @@ public sealed class A2AAgent : AIAgent
|
||||
ResponseId = statusUpdateEvent.TaskId,
|
||||
RawRepresentation = statusUpdateEvent,
|
||||
Role = ChatRole.Assistant,
|
||||
MessageId = statusUpdateEvent.Status.Message?.MessageId,
|
||||
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
|
||||
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
|
||||
Contents = statusUpdateEvent.Status.GetUserInputRequests(),
|
||||
|
||||
@@ -20,8 +20,28 @@ internal static class AGUIChatMessageExtensions
|
||||
this IEnumerable<AGUIMessage> aguiMessages,
|
||||
JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
// Coalesce consecutive AGUIAssistantMessages that carry tool_calls into a single
|
||||
// ChatMessage. The AG-UI client (e.g. @ag-ui/client) creates a separate assistant
|
||||
// message per tool call when ToolCallStartEvent.parentMessageId is empty, but
|
||||
// OpenAI's chat-completion API requires every assistant message with tool_calls
|
||||
// to be IMMEDIATELY followed by tool responses for each of its tool_call_ids.
|
||||
// Sending two consecutive single-tool-call assistant messages before any tool
|
||||
// result triggers HTTP 400 "tool_call_ids did not have response messages".
|
||||
List<AIContent>? pendingContents = null;
|
||||
string? pendingId = null;
|
||||
|
||||
foreach (var message in aguiMessages)
|
||||
{
|
||||
bool isAssistantWithToolCalls =
|
||||
message is AGUIAssistantMessage am && am.ToolCalls is { Length: > 0 };
|
||||
|
||||
if (pendingContents is not null && !isAssistantWithToolCalls)
|
||||
{
|
||||
yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId };
|
||||
pendingContents = null;
|
||||
pendingId = null;
|
||||
}
|
||||
|
||||
var role = MapChatRole(message.Role);
|
||||
|
||||
switch (message)
|
||||
@@ -84,14 +104,14 @@ internal static class AGUIChatMessageExtensions
|
||||
|
||||
case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
|
||||
{
|
||||
var contents = new List<AIContent>();
|
||||
pendingContents ??= new List<AIContent>();
|
||||
pendingId ??= message.Id;
|
||||
|
||||
if (!string.IsNullOrEmpty(assistantMessage.Content))
|
||||
{
|
||||
contents.Add(new TextContent(assistantMessage.Content));
|
||||
pendingContents.Add(new TextContent(assistantMessage.Content));
|
||||
}
|
||||
|
||||
// Add tool calls
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
{
|
||||
Dictionary<string, object?>? arguments = null;
|
||||
@@ -102,16 +122,12 @@ internal static class AGUIChatMessageExtensions
|
||||
jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)));
|
||||
}
|
||||
|
||||
contents.Add(new FunctionCallContent(
|
||||
pendingContents.Add(new FunctionCallContent(
|
||||
toolCall.Id,
|
||||
toolCall.Function.Name,
|
||||
arguments));
|
||||
}
|
||||
|
||||
yield return new ChatMessage(role, contents)
|
||||
{
|
||||
MessageId = message.Id
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -134,6 +150,12 @@ internal static class AGUIChatMessageExtensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining pending assistant-tool-call entry at end of stream.
|
||||
if (pendingContents is not null)
|
||||
{
|
||||
yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId };
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<AGUIMessage> AsAGUIMessages(
|
||||
|
||||
@@ -448,24 +448,36 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
};
|
||||
|
||||
string? currentMessageId = null;
|
||||
string? streamingMessageId = null;
|
||||
string? textStreamingFallback = null;
|
||||
bool textInFallback = false;
|
||||
string? currentReasoningBaseId = null;
|
||||
string? currentReasoningId = null;
|
||||
string? currentReasoningMessageId = null;
|
||||
await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Generate a fallback MessageId when the provider doesn't supply one.
|
||||
// This ensures all AGUI events have a valid messageId regardless of agent type.
|
||||
if (string.IsNullOrWhiteSpace(chatResponse.MessageId))
|
||||
// The text-event surface (TextMessageStart/Content/End) requires a non-empty
|
||||
// MessageId to be valid AGUI. Generate a fallback scoped to a contiguous run of
|
||||
// null/empty-MessageId chunks (one logical text message). Leave the raw
|
||||
// chatResponse.MessageId untouched so the tool-call surface below uses the raw
|
||||
// provider value — collapsing parallel tool calls under a synthetic shared parent
|
||||
// would make the FE render them as one assistant-message bubble instead of
|
||||
// distinct rows.
|
||||
string? textMessageId = chatResponse.MessageId;
|
||||
if (string.IsNullOrWhiteSpace(textMessageId))
|
||||
{
|
||||
chatResponse.MessageId = ContainsToolResult(chatResponse)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: (streamingMessageId ??= Guid.NewGuid().ToString("N"));
|
||||
textStreamingFallback ??= Guid.NewGuid().ToString("N");
|
||||
textMessageId = textStreamingFallback;
|
||||
textInFallback = true;
|
||||
}
|
||||
else if (textInFallback)
|
||||
{
|
||||
textStreamingFallback = null;
|
||||
textInFallback = false;
|
||||
}
|
||||
|
||||
if (chatResponse is { Contents.Count: > 0 } &&
|
||||
chatResponse.Contents[0] is TextContent &&
|
||||
!string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
|
||||
!string.Equals(currentMessageId, textMessageId, StringComparison.Ordinal))
|
||||
{
|
||||
// Close any open reasoning block before opening a text message, so AG-UI
|
||||
// events are properly bracketed. MEAI providers share one MessageId across
|
||||
@@ -498,11 +510,11 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
// Start the new message
|
||||
yield return new TextMessageStartEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId!,
|
||||
MessageId = textMessageId!,
|
||||
Role = chatResponse.Role!.Value.Value
|
||||
};
|
||||
|
||||
currentMessageId = chatResponse.MessageId;
|
||||
currentMessageId = textMessageId;
|
||||
}
|
||||
|
||||
// Emit text content if present
|
||||
@@ -577,9 +589,15 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
currentReasoningMessageId = null;
|
||||
}
|
||||
|
||||
// Each tool result is a distinct tool-role message on the AGUI wire.
|
||||
// MEAI's FunctionInvokingChatClient shares one synthetic MessageId
|
||||
// across all FunctionResultContent items, but the FE keys messages
|
||||
// by id, so emitting them with the same id collapses them in React
|
||||
// reconciliation. Derive a unique, deterministic per-result id from
|
||||
// the (LLM-assigned) call id.
|
||||
yield return new ToolCallResultEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId,
|
||||
MessageId = $"result-{functionResultContent.CallId}",
|
||||
ToolCallId = functionResultContent.CallId,
|
||||
Content = SerializeResultContent(functionResultContent, jsonSerializerOptions) ?? "",
|
||||
Role = AGUIRoles.Tool
|
||||
@@ -674,7 +692,7 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
// Text content event
|
||||
yield return new TextMessageContentEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId!,
|
||||
MessageId = textMessageId!,
|
||||
#if !NET
|
||||
Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray())
|
||||
#else
|
||||
@@ -726,17 +744,4 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
_ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ContainsToolResult(ChatResponseUpdate chatResponse)
|
||||
{
|
||||
foreach (AIContent content in chatResponse.Contents)
|
||||
{
|
||||
if (content is FunctionResultContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## v1.0.0-preview.260219.1
|
||||
|
||||
- [BREAKING] Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
|
||||
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
|
||||
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays #3803
|
||||
- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973))
|
||||
|
||||
## v1.0.0-preview.260212.1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
@@ -9,8 +10,11 @@ using Microsoft.Extensions.AI;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that appends the hosted-agent <c>User-Agent</c> segment
|
||||
/// (e.g. <c>"foundry-hosting/agent-framework-dotnet/{version}"</c>) to outgoing requests.
|
||||
/// Pipeline policy that emits the hosted-agent <c>User-Agent</c> segment
|
||||
/// (<c>"foundry-hosting/agent-framework-dotnet/{version}"</c>), matching Python's hosted
|
||||
/// contract (<c>foundry-hosting/agent-framework-python/{version}</c>, see
|
||||
/// <c>python/packages/core/agent_framework/_telemetry.py</c>: the hosted prefix is joined
|
||||
/// with the base agent-framework segment into a single combined User-Agent value).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -19,6 +23,12 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// is already present in the <c>User-Agent</c> header, the policy does not append it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a bare <c>agent-framework-dotnet/{version}</c> segment is already present (stamped by
|
||||
/// the framework-wide <c>AgentFrameworkUserAgentPolicy</c> registered by
|
||||
/// <c>FoundryChatClient</c>), this policy <em>replaces</em> that segment with the combined
|
||||
/// hosted form so the wire never carries both forms simultaneously, preserving Python parity.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This policy is added at hosted-agent resolution time via the MEAI 10.5.1
|
||||
/// <see cref="OpenAIRequestPolicies"/> hook on the agent's underlying chat client. It is only
|
||||
/// registered when an agent is resolved by the Foundry hosting layer.
|
||||
@@ -30,6 +40,12 @@ internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
|
||||
|
||||
private static readonly string s_supplementValue = CreateSupplementValue();
|
||||
|
||||
/// <summary>Bare segment stamped by <c>AgentFrameworkUserAgentPolicy</c> in the non-hosted scenario; this policy upgrades it in-place when both run.</summary>
|
||||
private const string BareAgentFrameworkPrefix = "agent-framework-dotnet/";
|
||||
|
||||
/// <summary>Combined hosted segment that this policy emits. Recognized in-place so callers whose pipelines already carry a (possibly different-version) combined segment get it replaced rather than double-prefixed (Q-D fix).</summary>
|
||||
private const string CombinedHostedPrefix = "foundry-hosting/agent-framework-dotnet/";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
@@ -46,13 +62,52 @@ internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
|
||||
{
|
||||
// Guard against double-append on retries or when the policy
|
||||
// is registered on multiple pipeline positions.
|
||||
if (existing.Contains(s_supplementValue))
|
||||
// Guard against double-append on retries or when the policy is registered on
|
||||
// multiple pipeline positions.
|
||||
if (existing!.Contains(s_supplementValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Combined-form check first: if the caller's pipeline already has
|
||||
// `foundry-hosting/agent-framework-dotnet/{version}` (with a version that differs
|
||||
// from ours — otherwise the .Contains above would have returned early), replace the
|
||||
// entire combined span in place. Without this, the bare-prefix search below would
|
||||
// match `agent-framework-dotnet/` *inside* the combined segment and produce a
|
||||
// malformed `foundry-hosting/foundry-hosting/agent-framework-dotnet/...` value.
|
||||
var combinedIdx = existing.IndexOf(CombinedHostedPrefix, StringComparison.Ordinal);
|
||||
if (combinedIdx >= 0)
|
||||
{
|
||||
var combinedEnd = existing.IndexOf(' ', combinedIdx);
|
||||
if (combinedEnd < 0)
|
||||
{
|
||||
combinedEnd = existing.Length;
|
||||
}
|
||||
|
||||
var replacedCombined = string.Concat(existing.AsSpan(0, combinedIdx), s_supplementValue.AsSpan(), existing.AsSpan(combinedEnd));
|
||||
message.Request.Headers.Set("User-Agent", replacedCombined);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the bare agent-framework segment is present (stamped by
|
||||
// AgentFrameworkUserAgentPolicy when not hosted), upgrade it in place to the
|
||||
// combined hosted form so the wire never carries both segments simultaneously.
|
||||
// Mirrors Python where get_user_agent() returns a single combined string when the
|
||||
// hosted prefix is registered.
|
||||
var idx = existing.IndexOf(BareAgentFrameworkPrefix, StringComparison.Ordinal);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var end = existing.IndexOf(' ', idx);
|
||||
if (end < 0)
|
||||
{
|
||||
end = existing.Length;
|
||||
}
|
||||
|
||||
var replaced = string.Concat(existing.AsSpan(0, idx), s_supplementValue.AsSpan(), existing.AsSpan(end));
|
||||
message.Request.Headers.Set("User-Agent", replaced);
|
||||
return;
|
||||
}
|
||||
|
||||
message.Request.Headers.Set("User-Agent", $"{existing} {s_supplementValue}");
|
||||
}
|
||||
else
|
||||
|
||||
+8
-11
@@ -23,7 +23,7 @@ namespace Azure.AI.Projects;
|
||||
/// Provides extension methods for <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static partial class AzureAIProjectChatClientExtensions
|
||||
public static partial class AIProjectClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="AgentReference"/>.
|
||||
@@ -63,7 +63,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
clientFactory,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
return new FoundryAgent(innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -132,7 +132,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
!allowDeclarativeMode,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
return new FoundryAgent(innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -165,7 +165,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
!allowDeclarativeMode,
|
||||
services);
|
||||
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
return new FoundryAgent(innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -246,7 +246,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -268,10 +268,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Throw.IfNull(agentOptions.ChatOptions);
|
||||
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
|
||||
|
||||
IChatClient chatClient = aiProjectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(agentOptions.ChatOptions.ModelId);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -298,7 +295,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -316,7 +313,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Framework-wide pipeline policy that appends the <c>agent-framework-dotnet/{version}</c>
|
||||
/// segment to outgoing <c>User-Agent</c> headers, mirroring the
|
||||
/// <c>agent-framework-python/{version}</c> contract used by every Python provider package.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The segment value is computed once from the <c>Microsoft.Agents.AI.Foundry</c> assembly's
|
||||
/// <see cref="AssemblyInformationalVersionAttribute"/>. The policy is idempotent on retries: if
|
||||
/// the segment is already present in the <c>User-Agent</c> header, the policy does not append
|
||||
/// it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The policy is registered by <c>FoundryChatClient</c> on the underlying chat client's
|
||||
/// <c>OpenAIRequestPolicies</c> hook so every outbound Foundry call carries the segment. The
|
||||
/// policy is currently colocated with the Foundry package; it is expected to migrate to a
|
||||
/// framework-wide location (such as <c>Microsoft.Agents.AI</c>) once another provider package
|
||||
/// adopts the same User-Agent contract.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AgentFrameworkUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
/// <summary>Gets the singleton policy instance.</summary>
|
||||
public static AgentFrameworkUserAgentPolicy Instance { get; } = new AgentFrameworkUserAgentPolicy();
|
||||
|
||||
private static readonly string s_segmentValue = CreateSegmentValue();
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AppendHeader(PipelineMessage message)
|
||||
{
|
||||
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
|
||||
{
|
||||
// Guard against double-append on retries or when the policy
|
||||
// is registered on multiple pipeline positions.
|
||||
if (existing!.Contains(s_segmentValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
message.Request.Headers.Set("User-Agent", $"{existing} {s_segmentValue}");
|
||||
}
|
||||
else
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", s_segmentValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateSegmentValue()
|
||||
{
|
||||
const string Name = "agent-framework-dotnet";
|
||||
|
||||
if (typeof(AgentFrameworkUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a chat client implementation that integrates with Azure AI Agents, enabling chat interactions using
|
||||
/// Azure-specific agent capabilities.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal sealed class AzureAIProjectChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ChatClientMetadata? _metadata;
|
||||
private readonly AIProjectClient _agentClient;
|
||||
private readonly ProjectsAgentVersion? _agentVersion;
|
||||
private readonly ProjectsAgentRecord? _agentRecord;
|
||||
private readonly ChatOptions? _chatOptions;
|
||||
private readonly AgentReference _agentReference;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AzureAIProjectChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">An instance of <see cref="AIProjectClient"/> to interact with Azure AI Agents services.</param>
|
||||
/// <param name="agentReference">An instance of <see cref="AgentReference"/> representing the specific agent to use.</param>
|
||||
/// <param name="defaultModelId">The default model to use for the agent, if applicable.</param>
|
||||
/// <param name="chatOptions">An instance of <see cref="ChatOptions"/> representing the options on how the agent was predefined.</param>
|
||||
/// <remarks>
|
||||
/// The <see cref="IChatClient"/> provided should be decorated with a <see cref="AzureAIProjectChatClient"/> for proper functionality.
|
||||
/// </remarks>
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? chatOptions)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForAgent(agentReference)
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._agentClient = aiProjectClient;
|
||||
this._agentReference = Throw.IfNull(agentReference);
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId);
|
||||
this._chatOptions = chatOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AzureAIProjectChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">An instance of <see cref="AIProjectClient"/> to interact with Azure AI Agents services.</param>
|
||||
/// <param name="agentRecord">An instance of <see cref="ProjectsAgentRecord"/> representing the specific agent to use.</param>
|
||||
/// <param name="chatOptions">An instance of <see cref="ChatOptions"/> representing the options on how the agent was predefined.</param>
|
||||
/// <remarks>
|
||||
/// The <see cref="IChatClient"/> provided should be decorated with a <see cref="AzureAIProjectChatClient"/> for proper functionality.
|
||||
/// </remarks>
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, ProjectsAgentRecord agentRecord, ChatOptions? chatOptions)
|
||||
: this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), chatOptions)
|
||||
{
|
||||
this._agentRecord = agentRecord;
|
||||
}
|
||||
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, ProjectsAgentVersion agentVersion, ChatOptions? chatOptions)
|
||||
: this(
|
||||
aiProjectClient,
|
||||
CreateAgentReference(Throw.IfNull(agentVersion)),
|
||||
(agentVersion.Definition as DeclarativeAgentDefinition)?.Model,
|
||||
chatOptions)
|
||||
{
|
||||
this._agentVersion = agentVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="AgentReference"/> from an <see cref="ProjectsAgentVersion"/>.
|
||||
/// Uses the agent version's version if available, otherwise defaults to "latest".
|
||||
/// </summary>
|
||||
/// <param name="agentVersion">The agent version to create a reference from.</param>
|
||||
/// <returns>An <see cref="AgentReference"/> for the specified agent version.</returns>
|
||||
private static AgentReference CreateAgentReference(ProjectsAgentVersion agentVersion)
|
||||
{
|
||||
// If the version is null, empty, or whitespace, use "latest" as the default.
|
||||
// This handles cases where hosted agents (like MCP agents) may not have a version assigned.
|
||||
var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version;
|
||||
return new AgentReference(agentVersion.Name, version);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return (serviceKey is null && serviceType == typeof(ChatClientMetadata))
|
||||
? this._metadata
|
||||
: (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
? this._agentClient
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentVersion))
|
||||
? this._agentVersion
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentRecord))
|
||||
? this._agentRecord
|
||||
: (serviceKey is null && serviceType == typeof(AgentReference))
|
||||
? this._agentReference
|
||||
: base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var agentOptions = this.GetAgentEnabledChatOptions(options);
|
||||
|
||||
return await base.GetResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var agentOptions = this.GetAgentEnabledChatOptions(options);
|
||||
|
||||
await foreach (var chunk in base.GetStreamingResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options)
|
||||
{
|
||||
// Start with a clone of the base chat options defined for the agent, if any.
|
||||
ChatOptions agentEnabledChatOptions = this._chatOptions?.Clone() ?? new();
|
||||
|
||||
// Ignore per-request all options that can't be overridden.
|
||||
agentEnabledChatOptions.Instructions = null;
|
||||
agentEnabledChatOptions.Tools = null;
|
||||
agentEnabledChatOptions.Temperature = null;
|
||||
agentEnabledChatOptions.TopP = null;
|
||||
agentEnabledChatOptions.PresencePenalty = null;
|
||||
agentEnabledChatOptions.ResponseFormat = null;
|
||||
|
||||
// Use the conversation from the request, or the one defined at the client level.
|
||||
agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._chatOptions?.ConversationId;
|
||||
|
||||
// Preserve the original RawRepresentationFactory
|
||||
var originalFactory = options?.RawRepresentationFactory;
|
||||
|
||||
agentEnabledChatOptions.RawRepresentationFactory = (client) =>
|
||||
{
|
||||
if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions)
|
||||
{
|
||||
responseCreationOptions = new CreateResponseOptions();
|
||||
}
|
||||
|
||||
responseCreationOptions.Agent = this._agentReference;
|
||||
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
responseCreationOptions.Patch.Remove("$.model"u8);
|
||||
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
|
||||
return responseCreationOptions;
|
||||
};
|
||||
|
||||
return agentEnabledChatOptions;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
internal sealed class AzureAIProjectResponsesChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ChatClientMetadata _metadata;
|
||||
private readonly AIProjectClient _aiProjectClient;
|
||||
|
||||
internal AzureAIProjectResponsesChatClient(AIProjectClient aiProjectClient, string defaultModelId)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(defaultModelId))
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId);
|
||||
}
|
||||
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return (serviceKey is null && serviceType == typeof(ChatClientMetadata))
|
||||
? this._metadata
|
||||
: (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
? this._aiProjectClient
|
||||
: base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
}
|
||||
#pragma warning restore OPENAI001
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Foundry-specific extensions on <see cref="ChatClientAgent"/>. Mirrors Python's free
|
||||
/// <c>to_prompt_agent(agent)</c> function for agents whose underlying chat client is a
|
||||
/// <see cref="FoundryChatClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class ChatClientAgentFoundryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the supplied agent into a <see cref="ProjectsAgentDefinition"/> ready to publish
|
||||
/// via <c>AgentAdministrationClient.CreateAgentVersionAsync</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only works on agents whose chat client is a <see cref="FoundryChatClient"/> and whose
|
||||
/// construction mode is convertible. The Agent Endpoint construction mode (Mode 3) is not
|
||||
/// convertible because no local definition exists; conversion in that case throws.
|
||||
/// </remarks>
|
||||
/// <param name="agent">The chat client agent to convert.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel an internal server-side fetch when the agent was constructed from a bare <see cref="AgentReference"/>.</param>
|
||||
/// <returns>A <see cref="ProjectsAgentDefinition"/> suitable for publishing.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent's chat client is not a <see cref="FoundryChatClient"/>; the agent was constructed via the Agent Endpoint mode (Mode 3); no model id is set on the agent's <see cref="ChatOptions"/> for the Responses Agent mode (Mode 1); or the agent contains an <see cref="AITool"/> that cannot be converted to a <c>ResponseTool</c>.</exception>
|
||||
public static Task<ProjectsAgentDefinition> ToPromptAgentAsync(this ChatClientAgent agent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
return FoundryPromptAgentConverter.ConvertAsync(agent.ChatClient, agent.GetService<ChatOptions>(), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,6 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryAgent : DelegatingAIAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// The cached <see cref="AIProjectClient"/> supplied to or constructed by the active constructor.
|
||||
/// </summary>
|
||||
private readonly AIProjectClient _aiProjectClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class using the direct Responses API path.
|
||||
/// </summary>
|
||||
@@ -73,9 +68,8 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
: base(CreateInnerAgent(
|
||||
CreateProjectClient(projectEndpoint, credential, clientOptions),
|
||||
model, instructions, name, description, tools, clientFactory, loggerFactory, services,
|
||||
out var aiProjectClient))
|
||||
out _))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -87,9 +81,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// </param>
|
||||
/// <param name="credential">The authentication credential.</param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional configuration for the underlying <see cref="ProjectResponsesClient"/>. When supplied:
|
||||
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The instance is passed through to the per-agent client; pipeline policies added via <c>AddPolicy(...)</c> on it execute on the per-agent traffic.</description></item>
|
||||
/// <item><description><c>Endpoint</c> and <see cref="ProjectOpenAIClientOptions.AgentName"/> are owned by this constructor and are overwritten with values derived from <paramref name="agentEndpoint"/>; any caller value is replaced.</description></item>
|
||||
/// <item><description>For the project-level conversations client a separate fresh options bag is built that copies only <see cref="ClientPipelineOptions.RetryPolicy"/>, <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>, and <c>UserAgentApplicationId</c>; pipeline policies added via <c>AddPolicy(...)</c> do <strong>not</strong> propagate to the conversations pipeline.</description></item>
|
||||
/// </list>
|
||||
/// </param>
|
||||
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
|
||||
@@ -113,43 +109,37 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services, out var aiProjectClient))
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific
|
||||
/// endpoint while reusing an existing <see cref="AIProjectClient"/>.
|
||||
/// Internal constructor used by the <c>AsAIAgent(this AIProjectClient, Uri, ...)</c>
|
||||
/// extension where the caller already has an <see cref="AIProjectClient"/> and the agent
|
||||
/// endpoint URI. Reuses the supplied client's pipeline (no new credential or transport is
|
||||
/// stamped) and surfaces the agent through a <see cref="FoundryChatClient"/> just like the
|
||||
/// public agent-endpoint ctor.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">An existing <see cref="AIProjectClient"/> rooted at the same project as <paramref name="agentEndpoint"/>.</param>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The agent-specific endpoint URI. Must be of the shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>.
|
||||
/// </param>
|
||||
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/>.</param>
|
||||
/// <param name="services">Optional service provider for resolving dependencies required by AI functions.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="aiProjectClient"/> or <paramref name="agentEndpoint"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
|
||||
internal FoundryAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
: base(BuildAgentEndpointInnerAgent(aiProjectClient, agentEndpoint, clientOptions: null, tools, clientFactory, services))
|
||||
: base(CreateInnerAgentFromAgentEndpointReusingProjectClient(aiProjectClient, agentEndpoint, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have an <see cref="AIProjectClient"/> and a configured <see cref="ChatClientAgent"/>.
|
||||
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have a
|
||||
/// configured <see cref="ChatClientAgent"/>. The inner agent already routes through a
|
||||
/// <see cref="FoundryChatClient"/> whose <c>GetService<AIProjectClient>()</c> surfaces
|
||||
/// the project client to downstream callers, so the agent does not also need a private
|
||||
/// <see cref="AIProjectClient"/> reference here.
|
||||
/// </summary>
|
||||
internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent)
|
||||
internal FoundryAgent(ChatClientAgent innerAgent)
|
||||
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
}
|
||||
|
||||
#region Convenience methods
|
||||
@@ -182,7 +172,13 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// <returns>A <see cref="ChatClientAgentSession"/> linked to the newly created server-side conversation.</returns>
|
||||
public async Task<ChatClientAgentSession> CreateConversationSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var conversationsClient = this._aiProjectClient.ProjectOpenAIClient.GetProjectConversationsClient();
|
||||
// The inner FoundryChatClient surfaces an AIProjectClient via GetService for all
|
||||
// three construction modes (Plan #2 Agent Endpoint mode materialization). Resolve it through the
|
||||
// delegating chain at call time instead of caching a private reference on this agent.
|
||||
var aiProjectClient = this.GetService<AIProjectClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"FoundryAgent inner chain does not expose an AIProjectClient; cannot create a project-level conversation session.");
|
||||
var conversationsClient = aiProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient();
|
||||
|
||||
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
|
||||
|
||||
@@ -196,17 +192,6 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
if (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
{
|
||||
return this._aiProjectClient;
|
||||
}
|
||||
|
||||
return base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
#region Private helpers
|
||||
|
||||
private static AIAgent CreateInnerAgent(
|
||||
@@ -251,7 +236,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
Throw.IfNull(agentOptions.ChatOptions);
|
||||
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);
|
||||
|
||||
IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
@@ -288,16 +273,10 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor by
|
||||
/// constructing a project-scoped <see cref="ProjectOpenAIClient"/> and using
|
||||
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
|
||||
/// This routes the outbound URL through the per-agent endpoint shape that the Foundry service
|
||||
/// expects for hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
|
||||
/// Caller-supplied <paramref name="clientOptions"/> are passed through to the per-agent
|
||||
/// client with <c>Endpoint</c> and
|
||||
/// <see cref="ProjectOpenAIClientOptions.AgentName"/> overridden by values derived from
|
||||
/// <paramref name="agentEndpoint"/>; any policies the caller added via <c>AddPolicy</c>
|
||||
/// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last.
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor. The
|
||||
/// per-agent <see cref="ProjectOpenAIClient"/> shape and URL parsing are owned by
|
||||
/// <see cref="FoundryChatClient"/>; we just construct it in the Agent Endpoint mode (Mode 3)
|
||||
/// and pass the inner chat client through any caller-provided <paramref name="clientFactory"/>.
|
||||
/// </summary>
|
||||
private static AIAgent CreateInnerAgentFromAgentEndpoint(
|
||||
Uri agentEndpoint,
|
||||
@@ -305,44 +284,14 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services,
|
||||
out AIProjectClient outClient)
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
outClient = CreateProjectClient(projectRoot, credential, CreateProjectClientOptions(clientOptions));
|
||||
IChatClient chatClient = new FoundryChatClient(agentEndpoint, credential, clientOptions);
|
||||
var agentName = ((FoundryChatClient)chatClient).AgentName!;
|
||||
|
||||
return BuildAgentEndpointInnerAgent(outClient, agentEndpoint, clientOptions, tools, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for an agent endpoint against a pre-built
|
||||
/// <see cref="AIProjectClient"/>. The caller is responsible for ensuring the supplied client
|
||||
/// is rooted at the same project as <paramref name="agentEndpoint"/>; the agent name is
|
||||
/// parsed from the endpoint URI and passed to
|
||||
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
|
||||
/// </summary>
|
||||
private static AIAgent BuildAgentEndpointInnerAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
|
||||
IChatClient chatClient = aiProjectClient.ProjectOpenAIClient
|
||||
.GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions)
|
||||
.AsIChatClient();
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
chatClient = clientFactory(chatClient);
|
||||
@@ -358,6 +307,46 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="CreateInnerAgentFromAgentEndpoint"/> that reuses an existing
|
||||
/// <see cref="AIProjectClient"/>'s pipeline instead of stamping a fresh credential. Used by
|
||||
/// the <c>AsAIAgent(AIProjectClient, Uri agentEndpoint, ...)</c> extension overload.
|
||||
/// </summary>
|
||||
private static AIAgent CreateInnerAgentFromAgentEndpointReusingProjectClient(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentEndpoint, clientOptions: null);
|
||||
var agentName = ((FoundryChatClient)chatClient).AgentName!;
|
||||
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
chatClient = clientFactory(chatClient);
|
||||
}
|
||||
|
||||
ChatClientAgentOptions agentOptions = new()
|
||||
{
|
||||
Id = agentName,
|
||||
Name = agentName,
|
||||
ChatOptions = new() { Tools = tools },
|
||||
};
|
||||
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI. Delegates to <see cref="FoundryChatClient.ParseAgentEndpoint(Uri)"/>
|
||||
/// so the chat client and the agent share a single source of truth for the URL shape.
|
||||
/// </summary>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
=> FoundryChatClient.ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI of shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>
|
||||
@@ -369,90 +358,12 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// strips query string and fragment. Throws <see cref="ArgumentException"/> for inputs that
|
||||
/// do not match the expected shape.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The endpoint is missing the <c>/agents/</c> segment, has an empty agent name, or has a
|
||||
/// suffix other than <c>/endpoint/protocols/openai</c>.
|
||||
/// </exception>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
const string AgentsSegment = "/agents/";
|
||||
const string ExpectedSuffix = "/endpoint/protocols/openai";
|
||||
|
||||
var path = agentEndpoint.AbsolutePath.TrimEnd('/');
|
||||
var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var afterAgents = path.Substring(idx + AgentsSegment.Length);
|
||||
var nextSlash = afterAgents.IndexOf('/');
|
||||
if (nextSlash <= 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' is missing the '<agentName>{ExpectedSuffix}' suffix.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var agentName = afterAgents.Substring(0, nextSlash);
|
||||
var suffix = afterAgents.Substring(nextSlash);
|
||||
if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var rootPath = path.Substring(0, idx);
|
||||
var projectRoot = new UriBuilder(agentEndpoint)
|
||||
{
|
||||
Path = rootPath,
|
||||
Query = string.Empty,
|
||||
Fragment = string.Empty,
|
||||
}.Uri;
|
||||
|
||||
return (agentName, projectRoot);
|
||||
}
|
||||
|
||||
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
|
||||
{
|
||||
Throw.IfNull(endpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
clientOptions ??= new AIProjectClientOptions();
|
||||
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
return new AIProjectClient(endpoint, credential, clientOptions);
|
||||
}
|
||||
|
||||
internal static AIProjectClientOptions? CreateProjectClientOptions(ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
if (clientOptions is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Copy pipeline behavior the caller configured on the per-agent options bag onto the
|
||||
// project-level options bag so the agent endpoint client honors it. UserAgentApplicationId
|
||||
// is project-level (not derived from the agent endpoint), so it must be carried through too.
|
||||
var projectOptions = new AIProjectClientOptions
|
||||
{
|
||||
Transport = clientOptions.Transport,
|
||||
RetryPolicy = clientOptions.RetryPolicy,
|
||||
NetworkTimeout = clientOptions.NetworkTimeout,
|
||||
MessageLoggingPolicy = clientOptions.MessageLoggingPolicy,
|
||||
UserAgentApplicationId = clientOptions.UserAgentApplicationId,
|
||||
};
|
||||
|
||||
if (clientOptions.ClientLoggingOptions is not null)
|
||||
{
|
||||
projectOptions.ClientLoggingOptions = clientOptions.ClientLoggingOptions;
|
||||
}
|
||||
|
||||
return projectOptions;
|
||||
return new AIProjectClient(endpoint, credential, clientOptions ?? new AIProjectClientOptions());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.VectorStores;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Foundry-specific extensions on <see cref="FoundryAgent"/>. Hosts the prompt-agent converter
|
||||
/// plus thin forwarders that surface the file and vector-store helpers from the inner
|
||||
/// <see cref="FoundryChatClient"/> at the agent level so callers do not need to drop down to
|
||||
/// <c>agent.GetService<FoundryChatClient>().X()</c> for common workflows.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryAgentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the supplied <see cref="FoundryAgent"/> into a <see cref="ProjectsAgentDefinition"/>
|
||||
/// ready to publish via <c>AgentAdministrationClient.CreateAgentVersionAsync</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Agent Endpoint construction mode (Mode 3) is not convertible because no local
|
||||
/// definition exists; conversion in that case throws <see cref="InvalidOperationException"/>.
|
||||
/// </remarks>
|
||||
/// <param name="agent">The Foundry agent to convert.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel an internal server-side fetch when the agent was constructed from a bare <see cref="AgentReference"/>.</param>
|
||||
/// <returns>A <see cref="ProjectsAgentDefinition"/> suitable for publishing.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent's chat client is not a <see cref="FoundryChatClient"/>; the agent was constructed via the Agent Endpoint mode (Mode 3); no model id is set on the agent's <see cref="ChatOptions"/> for the Responses Agent mode (Mode 1); or the agent contains an <see cref="AITool"/> that cannot be converted to a <c>ResponseTool</c>.</exception>
|
||||
public static Task<ProjectsAgentDefinition> ToPromptAgentAsync(this FoundryAgent agent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
|
||||
var innerChatClient = agent.GetService<IChatClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync could not resolve the inner IChatClient on the FoundryAgent.");
|
||||
var chatOptions = agent.GetService<ChatOptions>();
|
||||
return FoundryPromptAgentConverter.ConvertAsync(innerChatClient, chatOptions, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a file to the project. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.UploadFileAsync(string, FileUploadPurpose, CancellationToken)"/>
|
||||
/// on the agent's inner <see cref="FoundryChatClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the upload pipeline.</param>
|
||||
/// <param name="filePath">Path to the file to upload.</param>
|
||||
/// <param name="purpose">The upload purpose (e.g. <see cref="FileUploadPurpose.Assistants"/>).</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the upload.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/> via <see cref="AIAgent.GetService{TService}(object?)"/>.</exception>
|
||||
public static Task<OpenAIFile> UploadFileAsync(this FoundryAgent agent, string filePath, FileUploadPurpose purpose, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).UploadFileAsync(filePath, purpose, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a previously uploaded file. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.DeleteFileAsync(string, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the file pipeline.</param>
|
||||
/// <param name="fileId">The file id returned by <see cref="UploadFileAsync(FoundryAgent, string, FileUploadPurpose, CancellationToken)"/>.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/>.</exception>
|
||||
public static Task<FileDeletionResult> DeleteFileAsync(this FoundryAgent agent, string fileId, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).DeleteFileAsync(fileId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the supplied files, creates a vector store containing them, and waits until the
|
||||
/// store leaves the in-progress state. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.CreateVectorStoreAsync(string, IEnumerable{string}, TimeSpan?, TimeSpan?, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the file and vector-store pipeline.</param>
|
||||
/// <param name="name">The vector store name.</param>
|
||||
/// <param name="filePaths">Paths to files to upload and attach to the store.</param>
|
||||
/// <param name="expiresAfter">Optional last-active-at expiration window.</param>
|
||||
/// <param name="pollingTimeout">Optional upper bound on the wait for the vector store to leave the in-progress state. Defaults to 5 minutes; pass <see cref="Timeout.InfiniteTimeSpan"/> to disable.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the orchestration.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/>.</exception>
|
||||
/// <exception cref="TimeoutException">The vector store did not leave the in-progress state within <paramref name="pollingTimeout"/>.</exception>
|
||||
public static Task<VectorStore> CreateVectorStoreAsync(this FoundryAgent agent, string name, IEnumerable<string> filePaths, TimeSpan? expiresAfter = null, TimeSpan? pollingTimeout = null, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).CreateVectorStoreAsync(name, filePaths, expiresAfter, pollingTimeout, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a vector store. Thin forwarder to
|
||||
/// <see cref="FoundryChatClient.DeleteVectorStoreAsync(string, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The Foundry agent whose inner chat client owns the vector-store pipeline.</param>
|
||||
/// <param name="vectorStoreId">The vector store id.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent does not expose a <see cref="FoundryChatClient"/>.</exception>
|
||||
public static Task<VectorStoreDeletionResult> DeleteVectorStoreAsync(this FoundryAgent agent, string vectorStoreId, CancellationToken cancellationToken = default)
|
||||
=> RequireFoundryChatClient(agent).DeleteVectorStoreAsync(vectorStoreId, cancellationToken);
|
||||
|
||||
private static FoundryChatClient RequireFoundryChatClient(FoundryAgent agent)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
return agent.GetService<FoundryChatClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"FoundryAgent does not expose a FoundryChatClient via GetService<FoundryChatClient>(). " +
|
||||
"File and vector-store helpers require the agent's inner chat client to be a FoundryChatClient.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.Responses;
|
||||
using OpenAI.VectorStores;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Foundry chat-client decorator that unifies the three Foundry chat-client construction
|
||||
/// modes (Responses Agent, Prompt Agent, Agent Endpoint) behind a single type and centralizes
|
||||
/// Foundry-specific concerns: <c>microsoft.foundry</c> telemetry tagging,
|
||||
/// <c>agent-framework-dotnet/{version}</c> User-Agent stamping, <c>x-ms-served-model</c>
|
||||
/// response-header capture, and (for Prompt Agents) per-request payload mutation that injects
|
||||
/// the agent reference and strips per-request overrides that the server owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Replaces the previous <c>AzureAIProjectChatClient</c> and <c>AzureAIProjectResponsesChatClient</c>
|
||||
/// decorators. All Foundry entry points (the public <c>FoundryAgent</c> constructors and the
|
||||
/// <c>AIProjectClientExtensions.AsAIAgent</c> overloads) now construct a
|
||||
/// <see cref="FoundryChatClient"/> internally, so telemetry and the agent-framework User-Agent
|
||||
/// segment are uniform across paths.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The three construction modes are:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Responses Agent</b> (Mode 1): direct Responses API call against a project-level model id; no server-side agent definition exists. Constructed from <c>(AIProjectClient, modelId)</c>.</description></item>
|
||||
/// <item><description><b>Prompt Agent</b> (Mode 2): server-side agent definition (a <see cref="ProjectsAgentDefinition"/>, typically a <see cref="DeclarativeAgentDefinition"/>) invoked by <see cref="AgentReference"/> against the project Responses URL. Constructed from <see cref="AgentReference"/>, <see cref="ProjectsAgentVersion"/>, or <see cref="ProjectsAgentRecord"/>.</description></item>
|
||||
/// <item><description><b>Agent Endpoint</b> (Mode 3): invocation via the per-agent endpoint URL <c>…/projects/{p}/agents/{name}/endpoint/protocols/openai</c>. The agent behind the endpoint can be either a hosted (container-backed) agent or a Prompt Agent. Constructed from <c>(Uri agentEndpoint, credential)</c>.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Note: "Hosted Agent" refers to a container-based runtime agent (see
|
||||
/// <c>Microsoft.Agents.AI.Foundry.Hosting</c>) and is the <i>kind</i> of agent that may sit
|
||||
/// behind an Agent Endpoint. It is not synonymous with the Agent Endpoint mode itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ChatClientMetadata _metadata;
|
||||
private readonly AIProjectClient? _aiProjectClient;
|
||||
private readonly AgentReference? _agentReference;
|
||||
private readonly ProjectsAgentVersion? _agentVersion;
|
||||
private readonly ProjectsAgentRecord? _agentRecord;
|
||||
private readonly ChatOptions? _baseChatOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Responses Agent mode (Mode 1): direct Responses API
|
||||
/// call against a project-level model id; no server-side agent definition exists.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The project client.</param>
|
||||
/// <param name="modelId">The model deployment id.</param>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, string modelId)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(modelId))
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId);
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Prompt Agent mode (Mode 2): server-side agent
|
||||
/// definition invoked by <see cref="AgentReference"/>.
|
||||
/// </summary>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? baseChatOptions)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForAgent(Throw.IfNull(agentReference))
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._agentReference = agentReference;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId);
|
||||
this._baseChatOptions = baseChatOptions;
|
||||
this.AgentName = agentReference.Name;
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Prompt Agent mode (Mode 2, record variant):
|
||||
/// server-side agent definition invoked by record, resolving to the latest version.
|
||||
/// </summary>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, ProjectsAgentRecord agentRecord, ChatOptions? baseChatOptions)
|
||||
: this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), baseChatOptions)
|
||||
{
|
||||
this._agentRecord = agentRecord;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Prompt Agent mode (Mode 2, version variant):
|
||||
/// server-side agent definition invoked by a specific version.
|
||||
/// </summary>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, ProjectsAgentVersion agentVersion, ChatOptions? baseChatOptions)
|
||||
: this(
|
||||
aiProjectClient,
|
||||
CreateAgentReference(Throw.IfNull(agentVersion)),
|
||||
(agentVersion.Definition as DeclarativeAgentDefinition)?.Model,
|
||||
baseChatOptions)
|
||||
{
|
||||
this._agentVersion = agentVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Agent Endpoint mode (Mode 3): invocation via the
|
||||
/// per-agent endpoint URL. Parses the URL into its per-agent
|
||||
/// <see cref="ProjectOpenAIClient"/> shape internally and forwards through the resulting
|
||||
/// responses client.
|
||||
/// </summary>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The agent-specific endpoint URI. Must be of the shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>.
|
||||
/// </param>
|
||||
/// <param name="credential">The authentication credential.</param>
|
||||
/// <param name="clientOptions">Optional per-agent client options. <c>Endpoint</c> and <c>AgentName</c> are owned by this ctor and overridden with values derived from <paramref name="agentEndpoint"/>.</param>
|
||||
internal FoundryChatClient(Uri agentEndpoint, AuthenticationTokenProvider credential, ProjectOpenAIClientOptions? clientOptions)
|
||||
: this(BuildAgentEndpointInner(agentEndpoint, credential, clientOptions))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance for the Agent Endpoint mode (Mode 3) by reusing an existing
|
||||
/// <see cref="AIProjectClient"/>'s pipeline. Equivalent to the
|
||||
/// <see cref="FoundryChatClient(Uri, AuthenticationTokenProvider, ProjectOpenAIClientOptions?)"/>
|
||||
/// constructor but skips building a fresh per-agent pipeline: the project-level
|
||||
/// <see cref="ProjectOpenAIClient"/> on <paramref name="aiProjectClient"/> is used directly.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The project client already configured at the project root containing <paramref name="agentEndpoint"/>.</param>
|
||||
/// <param name="agentEndpoint">The per-agent endpoint URI. Same shape constraints as the other agent-endpoint ctor.</param>
|
||||
/// <param name="clientOptions">Optional per-agent client options applied to the per-agent <c>GetProjectResponsesClientForAgentEndpoint</c> call.</param>
|
||||
internal FoundryChatClient(AIProjectClient aiProjectClient, Uri agentEndpoint, ProjectOpenAIClientOptions? clientOptions)
|
||||
: this(BuildAgentEndpointInnerFromProjectClient(aiProjectClient, agentEndpoint, clientOptions))
|
||||
{
|
||||
}
|
||||
|
||||
private FoundryChatClient(AgentEndpointInner inner)
|
||||
: base(inner.ChatClient)
|
||||
{
|
||||
this._aiProjectClient = inner.AIProjectClient;
|
||||
this.AgentName = inner.AgentName;
|
||||
this._metadata = new ChatClientMetadata("microsoft.foundry");
|
||||
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
|
||||
TryRegisterServedModelPolicy(this.InnerClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent name associated with this chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Set in two cases:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Prompt Agent mode (Mode 2): the value of <see cref="AgentReference.Name"/> supplied at
|
||||
/// construction.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// Agent Endpoint mode (Mode 3): the agent name segment parsed from the supplied agent
|
||||
/// endpoint URI.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Returns <see langword="null"/> for the Responses Agent mode (Mode 1) where no agent name
|
||||
/// exists.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string? AgentName { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return (serviceKey is null && serviceType == typeof(ChatClientMetadata))
|
||||
? this._metadata
|
||||
: (serviceKey is null && serviceType == typeof(AIProjectClient))
|
||||
? this._aiProjectClient
|
||||
: (serviceKey is null && serviceType == typeof(AgentReference))
|
||||
? this._agentReference
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentVersion))
|
||||
? this._agentVersion
|
||||
: (serviceKey is null && serviceType == typeof(ProjectsAgentRecord))
|
||||
? this._agentRecord
|
||||
: base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var effectiveOptions = this._agentReference is not null
|
||||
? this.GetAgentEnabledChatOptions(options)
|
||||
: options;
|
||||
|
||||
var box = new StrongBox<string?>(null);
|
||||
var previous = ServedModelScope.Current;
|
||||
ServedModelScope.Current = box;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (box.Value is { } servedModel)
|
||||
{
|
||||
response.ModelId = servedModel;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ServedModelScope.Current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var effectiveOptions = this._agentReference is not null
|
||||
? this.GetAgentEnabledChatOptions(options)
|
||||
: options;
|
||||
|
||||
var box = new StrongBox<string?>(null);
|
||||
var previous = ServedModelScope.Current;
|
||||
ServedModelScope.Current = box;
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (box.Value is { } servedModel)
|
||||
{
|
||||
chunk.ModelId = servedModel;
|
||||
}
|
||||
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ServedModelScope.Current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
#region File and vector-store helpers (mirrors Python's foundry_chat_client surface)
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a single file to the project for the supplied purpose. The upload is performed
|
||||
/// against the project-level <see cref="AIProjectClient"/> reachable via
|
||||
/// <see cref="GetService(Type, object?)"/>, so this method works uniformly across all three
|
||||
/// FoundryChatClient construction modes.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Absolute or relative path to the file to upload. The file must exist.</param>
|
||||
/// <param name="purpose">The file upload purpose (e.g. <see cref="FileUploadPurpose.Assistants"/>).</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the upload.</param>
|
||||
/// <returns>The created <see cref="OpenAIFile"/> as returned by the service.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="filePath"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="FileNotFoundException">The file at <paramref name="filePath"/> does not exist.</exception>
|
||||
public async Task<OpenAIFile> UploadFileAsync(string filePath, FileUploadPurpose purpose, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(filePath);
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"File not found: '{filePath}'.", filePath);
|
||||
}
|
||||
|
||||
var fileClient = this.GetOpenAIFileClient();
|
||||
// Use the Stream overload to honor cancellation; the (string, purpose) overload has no
|
||||
// CancellationToken parameter in the OpenAI SDK.
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var result = await fileClient.UploadFileAsync(stream, Path.GetFileName(filePath), purpose, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a file previously uploaded to the project.</summary>
|
||||
/// <param name="fileId">The file id returned by <see cref="UploadFileAsync(string, FileUploadPurpose, CancellationToken)"/>.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <returns>The deletion result.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="fileId"/> is <see langword="null"/> or whitespace.</exception>
|
||||
public async Task<FileDeletionResult> DeleteFileAsync(string fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(fileId);
|
||||
var fileClient = this.GetOpenAIFileClient();
|
||||
var result = await fileClient.DeleteFileAsync(fileId, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the supplied files, creates a vector store containing them, waits until the
|
||||
/// store finishes ingesting its files (status leaves <see cref="VectorStoreStatus.InProgress"/>),
|
||||
/// and returns the <see cref="VectorStore"/>. Mirrors Python's
|
||||
/// <c>foundry_chat_client.create_vector_store(name, files, expires_after_days)</c>.
|
||||
/// </summary>
|
||||
/// <param name="name">The vector store name.</param>
|
||||
/// <param name="filePaths">Paths to files to upload and attach to the store.</param>
|
||||
/// <param name="expiresAfter">Optional last-active-at expiration window. When supplied, the vector store expires this many days after its last use.</param>
|
||||
/// <param name="pollingTimeout">Optional upper bound on the wait for the vector store to leave <see cref="VectorStoreStatus.InProgress"/>. Defaults to 5 minutes when not supplied; pass <see cref="Timeout.InfiniteTimeSpan"/> to disable. Independent of <paramref name="cancellationToken"/>: cancellation always wins.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the orchestration.</param>
|
||||
/// <returns>The created and fully-ready <see cref="VectorStore"/>. The returned instance reflects the state observed after polling completes; it may be in <see cref="VectorStoreStatus.Completed"/> (typical), <see cref="VectorStoreStatus.Expired"/>, or any other terminal status returned by the service. Only <see cref="VectorStoreStatus.InProgress"/> is polled.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// File-upload semantics are best-effort: when one of the per-file uploads throws, this method
|
||||
/// makes a best-effort attempt to delete the files it has already uploaded so they do not
|
||||
/// accumulate as orphaned resources on the project, then rethrows the original exception. The
|
||||
/// cleanup itself does not throw — its failures are silently ignored because the caller is
|
||||
/// already receiving a more meaningful exception from the original upload failure.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cancellation aborts the polling loop with an <see cref="OperationCanceledException"/>; any
|
||||
/// already-uploaded files and the partially-created vector store remain on the project and are
|
||||
/// the caller's responsibility to clean up. The same applies when the polling timeout elapses
|
||||
/// (a <see cref="TimeoutException"/> is thrown instead).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException"><paramref name="name"/> is <see langword="null"/> or whitespace, or <paramref name="filePaths"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="TimeoutException">The vector store did not leave <see cref="VectorStoreStatus.InProgress"/> within <paramref name="pollingTimeout"/>.</exception>
|
||||
public async Task<VectorStore> CreateVectorStoreAsync(string name, IEnumerable<string> filePaths, TimeSpan? expiresAfter = null, TimeSpan? pollingTimeout = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
Throw.IfNull(filePaths);
|
||||
|
||||
var fileIds = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var path in filePaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var uploaded = await this.UploadFileAsync(path, FileUploadPurpose.Assistants, cancellationToken).ConfigureAwait(false);
|
||||
fileIds.Add(uploaded.Id);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Q-B: best-effort cleanup of files already uploaded before the mid-loop failure so
|
||||
// they do not accumulate as orphaned resources on the project. Swallow cleanup
|
||||
// exceptions — the caller is already going to see the original upload exception, and
|
||||
// there is nothing useful we can do with a secondary delete failure.
|
||||
await this.BestEffortDeleteFilesAsync(fileIds).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
var options = new VectorStoreCreationOptions
|
||||
{
|
||||
Name = name,
|
||||
};
|
||||
foreach (var id in fileIds)
|
||||
{
|
||||
options.FileIds.Add(id);
|
||||
}
|
||||
if (expiresAfter is { } window)
|
||||
{
|
||||
options.ExpirationPolicy = new VectorStoreExpirationPolicy(VectorStoreExpirationAnchor.LastActiveAt, (int)Math.Ceiling(window.TotalDays));
|
||||
}
|
||||
|
||||
var vectorStoreClient = this.GetVectorStoreClient();
|
||||
var createResult = await vectorStoreClient.CreateVectorStoreAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
var created = createResult.Value;
|
||||
|
||||
// Q-A: poll until the vector store leaves the in-progress state. Without this the helper
|
||||
// hands the caller a vector store whose file ingestion may still be running, defeating
|
||||
// the purpose of the one-call wrapper.
|
||||
return await WaitForVectorStoreReadyAsync(vectorStoreClient, created, pollingTimeout ?? s_defaultPollingTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task BestEffortDeleteFilesAsync(IEnumerable<string> fileIds)
|
||||
{
|
||||
foreach (var id in fileIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Pass CancellationToken.None: cleanup runs in the catch path; the caller's
|
||||
// token may already be cancelled and we still want to do our best to free
|
||||
// orphaned resources before propagating the original exception.
|
||||
await this.DeleteFileAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore cleanup failures; see XML doc on CreateVectorStoreAsync.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on <see cref="WaitForVectorStoreReadyAsync"/> when the caller does not supply one. Chosen to comfortably cover normal Foundry vector-store ingestion (seconds to a minute for modest file sets) while still surfacing a clear failure if the server is stuck.</summary>
|
||||
private static readonly TimeSpan s_defaultPollingTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
private static async Task<VectorStore> WaitForVectorStoreReadyAsync(VectorStoreClient client, VectorStore initial, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (initial.Status != VectorStoreStatus.InProgress)
|
||||
{
|
||||
return initial;
|
||||
}
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var delay = TimeSpan.FromMilliseconds(250);
|
||||
var maxDelay = TimeSpan.FromSeconds(2);
|
||||
var current = initial;
|
||||
while (current.Status == VectorStoreStatus.InProgress)
|
||||
{
|
||||
if (timeout != Timeout.InfiniteTimeSpan && stopwatch.Elapsed >= timeout)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"Vector store '{current.Id}' did not leave the in-progress state within {timeout.TotalSeconds:0.##} seconds.");
|
||||
}
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
var refreshed = await client.GetVectorStoreAsync(current.Id, cancellationToken).ConfigureAwait(false);
|
||||
current = refreshed.Value;
|
||||
|
||||
if (delay < maxDelay)
|
||||
{
|
||||
var next = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
|
||||
delay = next < maxDelay ? next : maxDelay;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a vector store. The associated files (if any) are not deleted by this method; call <see cref="DeleteFileAsync(string, CancellationToken)"/> separately to clean them up.</summary>
|
||||
/// <param name="vectorStoreId">The vector store id.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel the delete.</param>
|
||||
/// <returns>The deletion result.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="vectorStoreId"/> is <see langword="null"/> or whitespace.</exception>
|
||||
public async Task<VectorStoreDeletionResult> DeleteVectorStoreAsync(string vectorStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhitespace(vectorStoreId);
|
||||
var vectorStoreClient = this.GetVectorStoreClient();
|
||||
var result = await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
private OpenAIFileClient GetOpenAIFileClient()
|
||||
{
|
||||
var projectClient = this._aiProjectClient
|
||||
?? throw new InvalidOperationException("This FoundryChatClient does not have an AIProjectClient available. File and vector-store helpers require an AIProjectClient.");
|
||||
return projectClient.GetProjectOpenAIClient().GetOpenAIFileClient();
|
||||
}
|
||||
|
||||
private VectorStoreClient GetVectorStoreClient()
|
||||
{
|
||||
var projectClient = this._aiProjectClient
|
||||
?? throw new InvalidOperationException("This FoundryChatClient does not have an AIProjectClient available. File and vector-store helpers require an AIProjectClient.");
|
||||
return projectClient.GetProjectOpenAIClient().GetVectorStoreClient();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI of shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>
|
||||
/// and returns the agent name and the derived project-root URI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tolerates trailing slash, casing variants on <c>/agents/</c> and the suffix segment, and
|
||||
/// strips query string and fragment. Throws <see cref="ArgumentException"/> for inputs that
|
||||
/// do not match the expected shape.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The endpoint is missing the <c>/agents/</c> segment, has an empty agent name, or has a
|
||||
/// suffix other than <c>/endpoint/protocols/openai</c>.
|
||||
/// </exception>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
const string AgentsSegment = "/agents/";
|
||||
const string ExpectedSuffix = "/endpoint/protocols/openai";
|
||||
|
||||
var path = agentEndpoint.AbsolutePath.TrimEnd('/');
|
||||
var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'. " +
|
||||
"If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var afterAgents = path.Substring(idx + AgentsSegment.Length);
|
||||
var nextSlash = afterAgents.IndexOf('/');
|
||||
if (nextSlash <= 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' is missing the '<agentName>{ExpectedSuffix}' suffix.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var agentName = afterAgents.Substring(0, nextSlash);
|
||||
var suffix = afterAgents.Substring(nextSlash);
|
||||
if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var rootPath = path.Substring(0, idx);
|
||||
var projectRoot = new UriBuilder(agentEndpoint)
|
||||
{
|
||||
Path = rootPath,
|
||||
Query = string.Empty,
|
||||
Fragment = string.Empty,
|
||||
}.Uri;
|
||||
|
||||
return (agentName, projectRoot);
|
||||
}
|
||||
|
||||
private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options)
|
||||
{
|
||||
// Start with a clone of the base chat options defined for the agent, if any.
|
||||
ChatOptions agentEnabledChatOptions = this._baseChatOptions?.Clone() ?? new();
|
||||
|
||||
// Ignore per-request all options that can't be overridden.
|
||||
agentEnabledChatOptions.Instructions = null;
|
||||
agentEnabledChatOptions.Tools = null;
|
||||
agentEnabledChatOptions.Temperature = null;
|
||||
agentEnabledChatOptions.TopP = null;
|
||||
agentEnabledChatOptions.PresencePenalty = null;
|
||||
agentEnabledChatOptions.ResponseFormat = null;
|
||||
|
||||
// Use the conversation from the request, or the one defined at the client level.
|
||||
agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._baseChatOptions?.ConversationId;
|
||||
|
||||
// Preserve the original RawRepresentationFactory.
|
||||
var originalFactory = options?.RawRepresentationFactory;
|
||||
|
||||
agentEnabledChatOptions.RawRepresentationFactory = (client) =>
|
||||
{
|
||||
if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions)
|
||||
{
|
||||
responseCreationOptions = new CreateResponseOptions();
|
||||
}
|
||||
|
||||
responseCreationOptions.Agent = this._agentReference;
|
||||
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
|
||||
responseCreationOptions.Patch.Remove("$.model"u8);
|
||||
#pragma warning restore SCME0001
|
||||
|
||||
return responseCreationOptions;
|
||||
};
|
||||
|
||||
return agentEnabledChatOptions;
|
||||
}
|
||||
|
||||
private static AgentReference CreateAgentReference(ProjectsAgentVersion agentVersion)
|
||||
{
|
||||
// If the version is null, empty, or whitespace, use "latest" as the default. This handles
|
||||
// cases where hosted agents (like MCP agents) may not have a version assigned.
|
||||
var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version;
|
||||
return new AgentReference(agentVersion.Name, version);
|
||||
}
|
||||
|
||||
private static AgentEndpointInner BuildAgentEndpointInner(
|
||||
Uri agentEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
var (agentName, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.Endpoint = agentEndpoint;
|
||||
perAgentOptions.AgentName = agentName;
|
||||
|
||||
var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
|
||||
var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
|
||||
|
||||
var chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
|
||||
|
||||
// Materialize a project-level AIProjectClient from the parsed project root so
|
||||
// GetService<AIProjectClient>() returns non-null for all FoundryChatClient
|
||||
// construction modes. Project-level helpers (file upload, vector store create/delete)
|
||||
// depend on this. RBAC for those calls is at the project level; if the supplied
|
||||
// credential lacks project-scope permissions, the SDK surfaces a clean 401/403 at
|
||||
// call time. The four observable primitive ClientPipelineOptions properties are
|
||||
// propagated from the caller's per-agent options bag so test-injected transports and
|
||||
// explicit RetryPolicy / NetworkTimeout / UserAgentApplicationId reach the
|
||||
// project-level pipeline. Pipeline policies added via AddPolicy on the caller bag are
|
||||
// NOT propagated because ClientPipelineOptions does not publicly enumerate policies.
|
||||
var aiProjectClientOptions = new AIProjectClientOptions();
|
||||
if (clientOptions is not null)
|
||||
{
|
||||
if (clientOptions.RetryPolicy is not null)
|
||||
{
|
||||
aiProjectClientOptions.RetryPolicy = clientOptions.RetryPolicy;
|
||||
}
|
||||
if (clientOptions.NetworkTimeout is not null)
|
||||
{
|
||||
aiProjectClientOptions.NetworkTimeout = clientOptions.NetworkTimeout;
|
||||
}
|
||||
if (clientOptions.Transport is not null)
|
||||
{
|
||||
aiProjectClientOptions.Transport = clientOptions.Transport;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
|
||||
{
|
||||
aiProjectClientOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
|
||||
}
|
||||
}
|
||||
var aiProjectClient = new AIProjectClient(projectRoot, credential, aiProjectClientOptions);
|
||||
|
||||
return new AgentEndpointInner(chatClient, aiProjectClient, agentName);
|
||||
}
|
||||
|
||||
private static AgentEndpointInner BuildAgentEndpointInnerFromProjectClient(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.Endpoint = agentEndpoint;
|
||||
perAgentOptions.AgentName = agentName;
|
||||
|
||||
var chatClient = aiProjectClient.GetProjectOpenAIClient()
|
||||
.GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions)
|
||||
.AsIChatClient();
|
||||
|
||||
// Reuse the caller's AIProjectClient verbatim — no new pipeline is materialized.
|
||||
return new AgentEndpointInner(chatClient, aiProjectClient, agentName);
|
||||
}
|
||||
|
||||
/// <summary>Best-effort registration of <see cref="AgentFrameworkUserAgentPolicy"/> via the MEAI <see cref="OpenAIRequestPolicies"/> hook with at-most-once dedup per pipeline.</summary>
|
||||
private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerClient)
|
||||
{
|
||||
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
// OpenAIRequestPoliciesReflection.AddPolicyIfMissing performs a check-then-add against
|
||||
// the private _entries collection on the OpenAIRequestPolicies instance, so the
|
||||
// policy is registered at most once even when many FoundryChatClient instances share
|
||||
// the same underlying chat client.
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
policies,
|
||||
AgentFrameworkUserAgentPolicy.Instance,
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort registration of <see cref="ServedModelPolicy"/> via the MEAI
|
||||
/// <see cref="OpenAIRequestPolicies"/> hook. The policy captures the
|
||||
/// <c>x-ms-served-model</c> response header from Azure OpenAI and writes it into
|
||||
/// <see cref="ServedModelScope"/> so the <see cref="GetResponseAsync"/> and
|
||||
/// <see cref="GetStreamingResponseAsync"/> overrides can overwrite
|
||||
/// <see cref="ChatResponse.ModelId"/> with the actual model snapshot.
|
||||
/// </summary>
|
||||
private static void TryRegisterServedModelPolicy(IChatClient? innerClient)
|
||||
{
|
||||
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
policies,
|
||||
ServedModelPolicy.Instance,
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default OAuth scope for the Azure AI resource. Matches the scope used by <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is accepted by the Foundry control plane.</summary>
|
||||
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
|
||||
|
||||
private readonly struct AgentEndpointInner
|
||||
{
|
||||
public AgentEndpointInner(IChatClient chatClient, AIProjectClient aiProjectClient, string agentName)
|
||||
{
|
||||
this.ChatClient = chatClient;
|
||||
this.AIProjectClient = aiProjectClient;
|
||||
this.AgentName = agentName;
|
||||
}
|
||||
|
||||
public IChatClient ChatClient { get; }
|
||||
public AIProjectClient AIProjectClient { get; }
|
||||
public string AgentName { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Shared internal implementation behind the public <c>ToPromptAgentAsync</c> extension methods
|
||||
/// on <see cref="ChatClientAgent"/> and <see cref="FoundryAgent"/>. Converts a Foundry-backed
|
||||
/// agent into a <see cref="ProjectsAgentDefinition"/> ready to publish via
|
||||
/// <see cref="AgentAdministrationClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Dispatch by <see cref="FoundryChatClient"/> construction mode (reachable via
|
||||
/// <see cref="IChatClient.GetService(Type, object?)"/>):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Responses Agent (Mode 1)</b>: synthesize a <see cref="DeclarativeAgentDefinition"/> from the agent's <see cref="ChatOptions"/>.</description></item>
|
||||
/// <item><description><b>Prompt Agent (Mode 2, cached version)</b>: return the cached <see cref="ProjectsAgentVersion.Definition"/>.</description></item>
|
||||
/// <item><description><b>Prompt Agent (Mode 2, AgentReference-only)</b>: fetch the latest version from the service and return its definition.</description></item>
|
||||
/// <item><description><b>Agent Endpoint (Mode 3)</b>: throw — no local definition exists to convert.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal static class FoundryPromptAgentConverter
|
||||
{
|
||||
/// <summary>Performs the conversion for an agent whose chat client and chat options are supplied.</summary>
|
||||
/// <param name="chatClient">The chat client extracted from the calling agent (must surface a <see cref="FoundryChatClient"/> via <see cref="IChatClient.GetService(Type, object?)"/>).</param>
|
||||
/// <param name="chatOptions">The agent's chat options (model id, instructions, temperature, top-p, tools). Required for the Responses Agent mode; ignored for the Prompt Agent mode.</param>
|
||||
/// <param name="cancellationToken">A token that can cancel a server-side fetch (Prompt Agent AgentReference path).</param>
|
||||
/// <returns>A <see cref="ProjectsAgentDefinition"/> suitable for <c>AgentAdministrationClient.CreateAgentVersionAsync</c>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the chat client is not Foundry-backed, the agent was constructed via the Agent Endpoint mode, no model id is set for the Responses Agent mode, or an unsupported <see cref="AITool"/> is encountered.</exception>
|
||||
public static async Task<ProjectsAgentDefinition> ConvertAsync(IChatClient chatClient, ChatOptions? chatOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
Throw.IfNull(chatClient);
|
||||
|
||||
var foundryChatClient = chatClient.GetService<FoundryChatClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync requires a FoundryChatClient-backed agent. " +
|
||||
"The supplied agent's chat client does not expose a FoundryChatClient via GetService<FoundryChatClient>().");
|
||||
|
||||
// Prompt Agent (Mode 2) with a cached server-side version (constructed via ProjectsAgentVersion or ProjectsAgentRecord).
|
||||
if (foundryChatClient.GetService<ProjectsAgentVersion>() is { } cachedVersion)
|
||||
{
|
||||
return cachedVersion.Definition;
|
||||
}
|
||||
|
||||
// Prompt Agent (Mode 2) AgentReference-only: fetch the agent definition from the service.
|
||||
// Honor a pinned AgentReference.Version when present (Q-C fix); fall back to the latest
|
||||
// version only when the reference is unpinned ("", null, or "latest").
|
||||
if (foundryChatClient.GetService<AgentReference>() is { } agentReference)
|
||||
{
|
||||
var aiProjectClient = foundryChatClient.GetService<AIProjectClient>()
|
||||
?? throw new InvalidOperationException(
|
||||
"Cannot fetch the agent version because the FoundryChatClient does not expose an AIProjectClient.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agentReference.Version)
|
||||
&& !string.Equals(agentReference.Version, "latest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var pinnedVersion = await aiProjectClient.AgentAdministrationClient
|
||||
.GetAgentVersionAsync(agentReference.Name, agentReference.Version, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return pinnedVersion.Value.Definition;
|
||||
}
|
||||
|
||||
var record = await aiProjectClient.AgentAdministrationClient
|
||||
.GetAgentAsync(agentReference.Name, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return record.Value.GetLatestVersion().Definition;
|
||||
}
|
||||
|
||||
// Agent Endpoint (Mode 3): AgentName is set (parsed from URL) but no AgentReference exists
|
||||
// locally. The agent definition lives only on the server and is not retrievable through this
|
||||
// chat client, so conversion is not supported here.
|
||||
if (foundryChatClient.AgentName is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync is not supported for agents constructed via the Agent Endpoint mode (Mode 3); " +
|
||||
"no local definition exists to convert.");
|
||||
}
|
||||
|
||||
// Responses Agent (Mode 1): synthesize from ChatOptions.
|
||||
return SynthesizeFromChatOptions(chatOptions);
|
||||
}
|
||||
|
||||
private static DeclarativeAgentDefinition SynthesizeFromChatOptions(ChatOptions? chatOptions)
|
||||
{
|
||||
if (chatOptions is null || string.IsNullOrWhiteSpace(chatOptions.ModelId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ToPromptAgentAsync requires a model id on the agent's ChatOptions to synthesize a prompt agent definition.");
|
||||
}
|
||||
|
||||
var definition = new DeclarativeAgentDefinition(chatOptions.ModelId!)
|
||||
{
|
||||
Instructions = chatOptions.Instructions,
|
||||
Temperature = chatOptions.Temperature,
|
||||
TopP = chatOptions.TopP,
|
||||
};
|
||||
|
||||
if (chatOptions.Tools is { Count: > 0 } tools)
|
||||
{
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
definition.Tools.Add(ConvertTool(tool));
|
||||
}
|
||||
}
|
||||
|
||||
return definition;
|
||||
}
|
||||
|
||||
private static ResponseTool ConvertTool(AITool tool)
|
||||
{
|
||||
Throw.IfNull(tool);
|
||||
|
||||
if (tool is AIFunction function)
|
||||
{
|
||||
// strictModeEnabled is intentionally true to match the Python spec's
|
||||
// default behavior. JsonSchema on AIFunction is a JsonElement; serialize via its
|
||||
// string form so the payload matches what callers pass elsewhere in this codebase.
|
||||
return ResponseTool.CreateFunctionTool(
|
||||
function.Name,
|
||||
BinaryData.FromString(function.JsonSchema.ToString() ?? "{}"),
|
||||
strictModeEnabled: true,
|
||||
function.Description);
|
||||
}
|
||||
|
||||
if (tool.GetService(typeof(ResponseTool)) is ResponseTool responseTool)
|
||||
{
|
||||
return responseTool;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot convert AITool of type '{tool.GetType().Name}' to a ResponseTool. " +
|
||||
"Only AIFunction and AITool instances that wrap a ResponseTool (such as those produced by FoundryAITool factories) are supported.");
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
internal static class RequestOptionsExtensions
|
||||
{
|
||||
/// <summary>Gets the singleton <see cref="PipelinePolicy"/> that adds a MEAI user-agent header.</summary>
|
||||
internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance;
|
||||
|
||||
/// <summary>Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header.</summary>
|
||||
private sealed class MeaiUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
public static MeaiUserAgentPolicy Instance { get; } = new MeaiUserAgentPolicy();
|
||||
|
||||
private static readonly string s_userAgentValue = CreateUserAgentValue();
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AddUserAgentHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AddUserAgentHeader(message);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private static void AddUserAgentHeader(PipelineMessage message) =>
|
||||
message.Request.Headers.Add("User-Agent", s_userAgentValue);
|
||||
|
||||
private static string CreateUserAgentValue()
|
||||
{
|
||||
const string Name = "MEAI";
|
||||
|
||||
if (typeof(MeaiUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that captures the <c>x-ms-served-model</c> response header from Azure OpenAI
|
||||
/// and stores it in <see cref="ServedModelScope"/> for consumption by <see cref="FoundryChatClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Azure OpenAI Responses API returns the deployment alias in <c>response.model</c> but the actual
|
||||
/// model snapshot (e.g. <c>gpt-5-nano-2025-08-07</c>) in the <c>x-ms-served-model</c> response header.
|
||||
/// This policy extracts the header after the HTTP roundtrip so the <see cref="FoundryChatClient"/>
|
||||
/// can overwrite <c>ChatResponse.ModelId</c> with the true model name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Registered once per <c>OpenAIRequestPolicies</c> instance via the MEAI 10.5.1 extension hook.
|
||||
/// When the header is absent (non-Azure endpoints), the scope is not set and the
|
||||
/// <see cref="FoundryChatClient"/> preserves the original model name.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ServedModelPolicy : PipelinePolicy
|
||||
{
|
||||
/// <summary>The Azure OpenAI response header that carries the actual served model name.</summary>
|
||||
internal const string ServedModelHeader = "x-ms-served-model";
|
||||
|
||||
public static ServedModelPolicy Instance { get; } = new ServedModelPolicy();
|
||||
|
||||
private ServedModelPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
CaptureServedModel(message);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
CaptureServedModel(message);
|
||||
}
|
||||
|
||||
private static void CaptureServedModel(PipelineMessage message)
|
||||
{
|
||||
if (message.Response is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.Response.Headers.TryGetValue(ServedModelHeader, out string? servedModel)
|
||||
&& !string.IsNullOrWhiteSpace(servedModel))
|
||||
{
|
||||
// Write into the box (reference-type mutation) so the value is visible to the
|
||||
// FoundryChatClient that pushed the box before calling the inner client.
|
||||
if (ServedModelScope.Current is { } box)
|
||||
{
|
||||
box.Value = servedModel.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// AsyncLocal carrier that bridges the <c>x-ms-served-model</c> response header value from the
|
||||
/// <see cref="ServedModelPolicy"/> running inside the SCM transport pipeline up to the
|
||||
/// <see cref="FoundryChatClient"/> decorator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Because <see cref="AsyncLocal{T}"/> mutations inside a child <c>async</c> method do not propagate
|
||||
/// back to the caller (copy-on-write semantics), this scope uses <see cref="StrongBox{T}"/> as an
|
||||
/// indirection layer. The <see cref="FoundryChatClient"/> pushes a fresh box onto the scope
|
||||
/// before calling the inner client; the <see cref="ServedModelPolicy"/> writes into the box's
|
||||
/// <see cref="StrongBox{T}.Value"/> (a reference-type mutation visible to anyone holding the same box).
|
||||
/// After the inner call returns, the client reads the box's value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class ServedModelScope
|
||||
{
|
||||
private static readonly AsyncLocal<StrongBox<string?>?> s_current = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-async-flow served model box.
|
||||
/// </summary>
|
||||
public static StrongBox<string?>? Current
|
||||
{
|
||||
get => s_current.Value;
|
||||
set => s_current.Value = value;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
@@ -200,6 +204,14 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
result.Tools.Add(new HostedWebSearchTool());
|
||||
}
|
||||
|
||||
#if NET
|
||||
if (options?.ShellExecutor is ShellExecutor shellExecutor)
|
||||
{
|
||||
result.Tools ??= [];
|
||||
result.Tools.Add(shellExecutor.AsAIFunction());
|
||||
}
|
||||
#endif
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -249,6 +261,22 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
providers.Add(skillsProvider);
|
||||
}
|
||||
|
||||
if (options?.BackgroundAgents is IEnumerable<AIAgent> backgroundAgents)
|
||||
{
|
||||
var materializedAgents = backgroundAgents.ToList();
|
||||
if (materializedAgents.Count > 0)
|
||||
{
|
||||
providers.Add(new BackgroundAgentsProvider(materializedAgents, options.BackgroundAgentsProviderOptions));
|
||||
}
|
||||
}
|
||||
|
||||
#if NET
|
||||
if (options?.ShellExecutor is ShellExecutor shellExecutor)
|
||||
{
|
||||
providers.Add(new ShellEnvironmentProvider(shellExecutor, options.ShellEnvironmentProviderOptions));
|
||||
}
|
||||
#endif
|
||||
|
||||
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
|
||||
{
|
||||
providers.AddRange(userProviders);
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
@@ -218,4 +221,49 @@ public sealed class HarnessAgentOptions
|
||||
/// This property is ignored when <see cref="DisableOpenTelemetry"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public string? OpenTelemetrySourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of background agents available for delegation via <see cref="BackgroundAgentsProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When non-null and non-empty, a <see cref="BackgroundAgentsProvider"/> is automatically included in the
|
||||
/// agent's context providers, enabling the agent to start, monitor, and retrieve results from background tasks.
|
||||
/// When <see langword="null"/> or empty, no <see cref="BackgroundAgentsProvider"/> is configured.
|
||||
/// Each agent in the collection must have a non-empty <see cref="AIAgent.Name"/> and names must be unique
|
||||
/// (case-insensitive). If these requirements are not met, <see cref="BackgroundAgentsProvider"/> will throw
|
||||
/// an <see cref="System.ArgumentException"/> during construction.
|
||||
/// </remarks>
|
||||
public IEnumerable<AIAgent>? BackgroundAgents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional configuration for the <see cref="BackgroundAgentsProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this to customize instructions or agent list formatting for the background agents feature.
|
||||
/// This property is ignored when <see cref="BackgroundAgents"/> is <see langword="null"/> or empty.
|
||||
/// </remarks>
|
||||
public BackgroundAgentsProviderOptions? BackgroundAgentsProviderOptions { get; set; }
|
||||
|
||||
#if NET
|
||||
/// <summary>
|
||||
/// Gets or sets the shell executor used to enable shell tool and environment probing via <see cref="ShellEnvironmentProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When non-null, a <see cref="ShellEnvironmentProvider"/> is automatically included in the agent's context
|
||||
/// providers (injecting OS/shell/CWD information into the system prompt), and the executor's
|
||||
/// <see cref="ShellExecutor.AsAIFunction"/> is registered as a callable tool.
|
||||
/// When <see langword="null"/> (the default), no shell features are enabled.
|
||||
/// </remarks>
|
||||
public ShellExecutor? ShellExecutor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional configuration for the <see cref="ShellEnvironmentProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this to customize which tools are probed, the probe timeout, shell family override,
|
||||
/// or the instructions formatter.
|
||||
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
|
||||
/// </remarks>
|
||||
public ShellEnvironmentProviderOptions? ShellEnvironmentProviderOptions { get; set; }
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Harness</Title>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods on <see cref="McpClient"/> that expose MCP server tools to a Microsoft
|
||||
/// Agent Framework agent with optional long-running task (SEP-2663) handling.
|
||||
/// </summary>
|
||||
public static class McpClientTaskExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists tools advertised by the connected MCP server and returns each as an
|
||||
/// <see cref="AIFunction"/>. Tools that declare <see cref="ToolTaskSupport.Required"/>
|
||||
/// are wrapped with task-aware behavior so an agent can transparently drive long-running
|
||||
/// invocations. All other tools — including those that declare
|
||||
/// <see cref="ToolTaskSupport.Optional"/> — are returned as-is, preserving inline
|
||||
/// (synchronous) invocation semantics by default.
|
||||
/// </summary>
|
||||
/// <param name="client">The connected MCP client.</param>
|
||||
/// <param name="options">
|
||||
/// Options that control the task lifecycle for task-capable tools.
|
||||
/// When <see langword="null"/>, defaults described on <see cref="McpTaskOptions"/> apply.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Token used to cancel listing the server's tools.</param>
|
||||
/// <returns>The tools, ready to pass to <c>AsAIAgent(tools: …)</c>.</returns>
|
||||
public static async Task<IReadOnlyList<AIFunction>> ListAgentToolsWithTaskSupportAsync(
|
||||
this McpClient client,
|
||||
McpTaskOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(client);
|
||||
|
||||
McpTaskOptions effectiveOptions = options ?? new McpTaskOptions();
|
||||
|
||||
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AIFunction[] result = new AIFunction[tools.Count];
|
||||
for (int i = 0; i < tools.Count; i++)
|
||||
{
|
||||
ToolTaskSupport? taskSupport = tools[i].ProtocolTool.Execution?.TaskSupport;
|
||||
if (taskSupport is ToolTaskSupport.Required)
|
||||
{
|
||||
result[i] = new TaskAwareMcpClientAIFunction(client, tools[i], effectiveOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
result[i] = tools[i];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Configures how an MCP client wrapper drives the
|
||||
/// <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks">MCP tasks</see>
|
||||
/// lifecycle when an underlying server tool returns a <c>CreateTaskResult</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// All members of this type are subject to change. The MCP task surface is experimental
|
||||
/// and tracks the in-flight specification.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class McpTaskOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the time-to-live the wrapper attaches to a newly created server-side task.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/> the wrapper omits the <c>ttl</c> hint and lets the server
|
||||
/// pick its own value. The server's chosen TTL is always authoritative.
|
||||
/// </remarks>
|
||||
public TimeSpan? DefaultTimeToLive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the wrapper should send
|
||||
/// <c>tasks/cancel</c> when the local <see cref="System.Threading.CancellationToken"/>
|
||||
/// fires during a tool invocation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <see langword="true"/>: a local cancellation means "the caller is giving up
|
||||
/// on this tool invocation" and the server-side task has no further consumer.
|
||||
/// </remarks>
|
||||
public bool CancelRemoteTaskOnLocalCancellation { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Mcp</RootNamespace>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001;MCPEXP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Title>Microsoft Agent Framework MCP</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for Model Context Protocol (MCP), including long-running task (SEP-2663) integration for MCP clients.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Disable package validation baseline until the first release -->
|
||||
<PropertyGroup>
|
||||
<PackageValidationBaselineVersion />
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Mcp.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIFunction"/> wrapper around an <see cref="McpClientTool"/> that drives the
|
||||
/// <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks">MCP long-running task</see>
|
||||
/// lifecycle (SEP-2663) on behalf of the agent's tool loop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The wrapper invokes the tool with task augmentation via
|
||||
/// <see cref="McpClient.CallToolAsTaskAsync"/>, polls to completion via
|
||||
/// <see cref="McpClient.PollTaskUntilCompleteAsync"/>, and fetches the result via
|
||||
/// <see cref="McpClient.GetTaskResultAsync"/>. The result is returned to the caller as a
|
||||
/// <see cref="JsonElement"/> containing the serialized <see cref="CallToolResult"/> — the
|
||||
/// same wire shape produced by <see cref="McpClientTool"/>.<see cref="AIFunction.InvokeAsync(AIFunctionArguments, CancellationToken)"/>
|
||||
/// so that downstream <see cref="FunctionResultContent"/> serialization is byte-identical to
|
||||
/// a non-task-augmented MCP tool call. The agent's function-calling loop is unaware that a
|
||||
/// task was used.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This wrapper is intended to be applied only to tools whose
|
||||
/// <see cref="ToolExecution.TaskSupport"/> is <see cref="ToolTaskSupport.Required"/>
|
||||
/// (selected by <see cref="McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync"/>).
|
||||
/// As a defensive fallback, if the server still rejects the task-augmented call with
|
||||
/// <see cref="McpErrorCode.MethodNotFound"/> (e.g. because tool-level capabilities changed
|
||||
/// between <c>tools/list</c> and invocation), the wrapper transparently falls back to a
|
||||
/// non-augmented call through the inner <see cref="McpClientTool"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class TaskAwareMcpClientAIFunction : AIFunction
|
||||
{
|
||||
private readonly McpClient _client;
|
||||
private readonly McpClientTool _inner;
|
||||
private readonly McpTaskOptions _options;
|
||||
|
||||
internal TaskAwareMcpClientAIFunction(McpClient client, McpClientTool inner, McpTaskOptions options)
|
||||
{
|
||||
_ = Throw.IfNull(client);
|
||||
_ = Throw.IfNull(inner);
|
||||
_ = Throw.IfNull(options);
|
||||
|
||||
this._client = client;
|
||||
this._inner = inner;
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => this._inner.Name;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => this._inner.Description;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement JsonSchema => this._inner.JsonSchema;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement? ReturnJsonSchema => this._inner.ReturnJsonSchema;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = Throw.IfNull(arguments);
|
||||
|
||||
McpTaskMetadata? metadata = null;
|
||||
if (this._options.DefaultTimeToLive is TimeSpan ttl)
|
||||
{
|
||||
metadata = new McpTaskMetadata { TimeToLive = ttl };
|
||||
}
|
||||
|
||||
McpTask task;
|
||||
try
|
||||
{
|
||||
task = await this._client.CallToolAsTaskAsync(
|
||||
this._inner.Name,
|
||||
arguments,
|
||||
taskMetadata: metadata,
|
||||
progress: null,
|
||||
options: null,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.MethodNotFound)
|
||||
{
|
||||
// Defensive fallback: the server's advertised TaskSupport indicated this tool
|
||||
// could be invoked as a task, but the server now rejects task augmentation for it
|
||||
// (e.g. capability changed between tools/list and invocation). Fall back to a
|
||||
// non-augmented call through the inner McpClientTool.
|
||||
return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await this.PollAndRetrieveResultAsync(task.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<JsonElement> PollAndRetrieveResultAsync(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
McpTask terminal = await this._client.PollTaskUntilCompleteAsync(taskId, options: null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return terminal.Status switch
|
||||
{
|
||||
McpTaskStatus.Completed => await this._client.GetTaskResultAsync(taskId, options: null, cancellationToken).ConfigureAwait(false),
|
||||
McpTaskStatus.Cancelled => throw new OperationCanceledException(FormatTerminalStatusMessage(taskId, terminal)),
|
||||
_ => throw new InvalidOperationException(FormatTerminalStatusMessage(taskId, terminal)),// Failed (or any future non-terminal-but-unhandled status that the poll loop returns).
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) when (this._options.CancelRemoteTaskOnLocalCancellation && cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await this.TryCancelTaskAsync(taskId).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatTerminalStatusMessage(string taskId, McpTask terminal)
|
||||
=> string.IsNullOrEmpty(terminal.StatusMessage)
|
||||
? $"MCP task '{taskId}' ended in terminal status '{terminal.Status}'."
|
||||
: $"MCP task '{taskId}' ended in terminal status '{terminal.Status}': {terminal.StatusMessage}";
|
||||
|
||||
private async Task TryCancelTaskAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
_ = await this._client.CancelTaskAsync(taskId, options: null, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cancellation; do not mask the original cancellation reason.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,7 @@ public sealed class DockerShellExecutor : ShellExecutor
|
||||
/// Build the AIFunction for this tool.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <paramref name="requireApproval"/> is <see langword="null"/>
|
||||
/// When <paramref name="requireApproval"/> is <see langword="true"/>
|
||||
/// (the default), the returned function is wrapped in
|
||||
/// <see cref="ApprovalRequiredAIFunction"/>. The caller must
|
||||
/// explicitly pass <see langword="false"/> to opt out of approval
|
||||
@@ -259,14 +259,12 @@ public sealed class DockerShellExecutor : ShellExecutor
|
||||
/// <param name="name">Function name surfaced to the model.</param>
|
||||
/// <param name="description">Function description for the model.</param>
|
||||
/// <param name="requireApproval">
|
||||
/// <see langword="true"/> or <see langword="null"/> (the default)
|
||||
/// wraps the function in <see cref="ApprovalRequiredAIFunction"/>;
|
||||
/// <see langword="true"/> (the default) wraps the function in
|
||||
/// <see cref="ApprovalRequiredAIFunction"/>;
|
||||
/// <see langword="false"/> opts out and returns the raw function.
|
||||
/// </param>
|
||||
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool? requireApproval = null)
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
{
|
||||
var effectiveRequireApproval = requireApproval ?? true;
|
||||
|
||||
description ??=
|
||||
"Execute a single shell command inside an isolated Docker container and return its " +
|
||||
"stdout, stderr, and exit code. The container has no network, no host filesystem access " +
|
||||
@@ -292,7 +290,7 @@ public sealed class DockerShellExecutor : ShellExecutor
|
||||
},
|
||||
new AIFunctionFactoryOptions { Name = name, Description = description });
|
||||
|
||||
return effectiveRequireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
|
||||
return requireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -325,7 +325,7 @@ public sealed class LocalShellExecutor : ShellExecutor
|
||||
/// container where the tool itself is the boundary).
|
||||
/// </param>
|
||||
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
|
||||
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
public override AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
|
||||
{
|
||||
if (!requireApproval && !this._acknowledgeUnsafe)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Tools.Shell;
|
||||
|
||||
@@ -65,6 +66,20 @@ public abstract class ShellExecutor : IAsyncDisposable
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public abstract Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Build an <see cref="AIFunction"/> bound to this executor, suitable for
|
||||
/// registering with an agent as a callable tool.
|
||||
/// </summary>
|
||||
/// <param name="name">Function name visible to the model.</param>
|
||||
/// <param name="description">Function description for the model.</param>
|
||||
/// <param name="requireApproval">
|
||||
/// When <see langword="true"/> (the default), wraps the function in
|
||||
/// <see cref="ApprovalRequiredAIFunction"/> so every invocation requires
|
||||
/// explicit user approval before executing.
|
||||
/// </param>
|
||||
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
|
||||
public abstract AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true);
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract ValueTask DisposeAsync();
|
||||
}
|
||||
|
||||
+6
-2
@@ -22,9 +22,13 @@ internal static class AgentProviderExtensions
|
||||
{
|
||||
IAsyncEnumerable<AgentResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
|
||||
|
||||
// Enable "autoSend" behavior if this is the workflow conversation.
|
||||
// Determine whether the target conversation is the workflow conversation
|
||||
// (used below to decide whether to mirror messages into the workflow conversation
|
||||
// when an agent runs against a different conversation). The caller's autoSend
|
||||
// value is honored as-is — when the workflow.yaml specifies autoSend: false the
|
||||
// raw agent output must not be streamed to the caller, even when the agent is
|
||||
// running on the workflow conversation.
|
||||
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
|
||||
autoSend |= isWorkflowConversation;
|
||||
|
||||
// Process the agent response updates.
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
|
||||
+7
@@ -71,6 +71,13 @@ internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResul
|
||||
[SendsMessage(typeof(ActionExecutorResult))]
|
||||
public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Establish the Foundry ProductContext on the current async logical context before
|
||||
// running any code that reads PropertyPath.VariableName / NamespaceAlias. ObjectModel
|
||||
// resolves those lazily against AsyncLocal<ProductContext>; when the workflow is
|
||||
// hosted (AsAIAgent + AddFoundryResponses) each HTTP request runs on a fresh logical
|
||||
// context where the build-thread setting does not flow.
|
||||
WorkflowDiagnostics.SetFoundryProduct();
|
||||
|
||||
if (this.Model.Disabled)
|
||||
{
|
||||
Debug.WriteLine($"DISABLED {this.GetType().Name} [{this.Id}]");
|
||||
|
||||
@@ -22,6 +22,11 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
public static string End(string id) => $"{id}_{nameof(End)}";
|
||||
}
|
||||
|
||||
// State keys for checkpoint persistence of iteration progress.
|
||||
private const string IndexStateKey = nameof(_index);
|
||||
private const string ValuesStateKey = nameof(_values);
|
||||
private const string HasValueStateKey = nameof(HasValue);
|
||||
|
||||
private int _index;
|
||||
private FormulaValue[] _values;
|
||||
|
||||
@@ -93,4 +98,45 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
|
||||
await context.QueueStateResetAsync(this.Model.Index, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Persists the iteration cursor (<see cref="_index"/>), the materialized item snapshot
|
||||
/// (<see cref="_values"/> as <see cref="PortableValue"/>[]), and <see cref="HasValue"/> so a
|
||||
/// foreach loop can resume mid-iteration after a checkpoint (e.g. when a <c>Question</c>
|
||||
/// inside the loop body pauses the workflow and the executor is re-instantiated on resume).
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
PortableValue[] portableValues = [.. this._values.Select(value => new PortableValue(value.AsPortable()))];
|
||||
|
||||
await context.QueueStateUpdateAsync(IndexStateKey, this._index, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.QueueStateUpdateAsync(ValuesStateKey, portableValues, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.QueueStateUpdateAsync(HasValueStateKey, this.HasValue, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Restores the iteration cursor, item snapshot, and <see cref="HasValue"/> recorded by
|
||||
/// <see cref="OnCheckpointingAsync"/>. The presence of the values snapshot is the source of
|
||||
/// truth for "this foreach was previously checkpointed"; if it is absent the executor keeps
|
||||
/// its constructor defaults (fresh-start semantics).
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
PortableValue[]? savedValues =
|
||||
await context.ReadStateAsync<PortableValue[]>(ValuesStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (savedValues is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._values = [.. savedValues.Select(value => value.ToFormula())];
|
||||
this._index = await context.ReadStateAsync<int>(IndexStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
this.HasValue = await context.ReadStateAsync<bool>(HasValueStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -192,13 +192,16 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.AgentOutput?.AutoSend is null)
|
||||
// AzureAgentOutput.AutoSend is never null — it returns a literal-false default
|
||||
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
|
||||
// explicit autoSend value from the implicit default, and treat the implicit
|
||||
// default as autoSend = true (the historical behavior for actions that omit
|
||||
// autoSend or have no output block at all).
|
||||
if (this.AgentOutput is { AutoSendIsDefaultValue: false } output)
|
||||
{
|
||||
return true;
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
}
|
||||
|
||||
EvaluationResult<bool> autoSendResult = this.Evaluator.GetValue(this.AgentOutput.AutoSend);
|
||||
|
||||
return autoSendResult.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+68
-3
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
@@ -103,6 +104,24 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
FunctionResultContent? matchingResult = functionResults
|
||||
.FirstOrDefault(r => r.CallId == this.Id);
|
||||
|
||||
// When the caller approved an approval-required function call but didn't execute it
|
||||
// locally (the hosted Foundry scenario, where mcp_approval_response is converted to a
|
||||
// ToolApprovalResponseContent only), invoke the registered AIFunction here so that the
|
||||
// declarative workflow can capture the result and continue (e.g. for downstream
|
||||
// SendActivity/PropertyPath consumers like {Local.Result}).
|
||||
if (matchingResult is null)
|
||||
{
|
||||
ToolApprovalResponseContent? approval = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalResponseContent>()
|
||||
.FirstOrDefault(r => r.RequestId == this.Id);
|
||||
|
||||
if (approval is { Approved: true })
|
||||
{
|
||||
matchingResult = await this.InvokeRegisteredFunctionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingResult is not null)
|
||||
{
|
||||
// Store the result in output variable
|
||||
@@ -241,6 +260,48 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
return conversationIdValue.Length == 0 ? null : conversationIdValue;
|
||||
}
|
||||
|
||||
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string functionName = this.GetFunctionName();
|
||||
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
|
||||
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
|
||||
|
||||
if (function is null)
|
||||
{
|
||||
return new FunctionResultContent(this.Id, result: null)
|
||||
{
|
||||
Exception = new InvalidOperationException(
|
||||
$"Function '{functionName}' is not registered with the agent provider."),
|
||||
};
|
||||
}
|
||||
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
|
||||
|
||||
object? result;
|
||||
try
|
||||
{
|
||||
result = await function.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return new FunctionResultContent(this.Id, result: null) { Exception = ex };
|
||||
}
|
||||
|
||||
// Match FunctionInvokingChatClient's serialization: pass strings through as-is and
|
||||
// JSON-serialize anything else so structured results remain consumable by downstream
|
||||
// PropertyPath consumers such as {Local.RefundResult}. Use AIJsonUtilities so the
|
||||
// same trim/AOT-friendly serializer chain used elsewhere in the framework is applied.
|
||||
string serialized = result switch
|
||||
{
|
||||
null => string.Empty,
|
||||
string s => s,
|
||||
_ => JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType())),
|
||||
};
|
||||
|
||||
return new FunctionResultContent(this.Id, serialized);
|
||||
}
|
||||
|
||||
private bool GetRequireApproval()
|
||||
{
|
||||
if (this.Model.RequireApproval is null)
|
||||
@@ -253,12 +314,16 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
|
||||
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
|
||||
// explicit autoSend value from the implicit default, and treat the implicit
|
||||
// default as autoSend = true (the historical behavior).
|
||||
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
|
||||
{
|
||||
return true;
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private Dictionary<string, object?>? GetArguments()
|
||||
|
||||
+7
-3
@@ -311,12 +311,16 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
|
||||
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
|
||||
// explicit autoSend value from the implicit default, and treat the implicit
|
||||
// default as autoSend = true (the historical behavior).
|
||||
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
|
||||
{
|
||||
return true;
|
||||
return this.Evaluator.GetValue(output.AutoSend).Value;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private string? GetConnectionName()
|
||||
|
||||
+13
-4
@@ -21,12 +21,21 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
|
||||
|
||||
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage message = new(ChatRole.Assistant, activityText);
|
||||
|
||||
// Emit an AgentResponseUpdateEvent so chat protocols (e.g. AsAIAgent) receive the
|
||||
// activity text as streaming chat content. This event is yielded by WorkflowSession
|
||||
// unconditionally, mirroring how AgentProviderExtensions surfaces autoSend agent
|
||||
// updates — without it, SendActivity output is dropped whenever the host runs with
|
||||
// includeWorkflowOutputsInResponse = false (the default).
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, activityText) { AuthorName = this.Id };
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Route through YieldOutputAsync so the activity participates in the workflow's
|
||||
// output-filter pipeline. The runner currently special-cases AgentResponse to
|
||||
// produce an AgentResponseEvent identical to the one we'd build by hand, so this
|
||||
// is behavior-preserving today and forward-compatible if filtering is ever
|
||||
// applied to agent responses.
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
|
||||
// produce an AgentResponseEvent identical to the one we'd build by hand, which
|
||||
// is the gated summary surfaced only when includeWorkflowOutputsInResponse = true.
|
||||
AgentResponse response = new([message]);
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,27 @@
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -106,6 +127,27 @@
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -169,6 +211,27 @@
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -232,6 +295,27 @@
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -295,6 +379,27 @@
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Content</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Resources</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.get_Scripts</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
@@ -316,6 +421,13 @@
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -323,6 +435,13 @@
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -330,6 +449,13 @@
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -337,6 +463,13 @@
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
@@ -344,6 +477,13 @@
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkill.GetContentAsync(System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
|
||||
@@ -29,8 +29,8 @@ namespace Microsoft.Agents.AI;
|
||||
/// <para>
|
||||
/// This provider exposes the following tools to the agent:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>AgentMode_Set</c> — Switch the agent's operating mode.</description></item>
|
||||
/// <item><description><c>AgentMode_Get</c> — Retrieve the agent's current operating mode.</description></item>
|
||||
/// <item><description><c>mode_set</c> — Switch the agent's operating mode.</description></item>
|
||||
/// <item><description><c>mode_get</c> — Retrieve the agent's current operating mode.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -49,8 +49,8 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
- You must check the current mode after any user input, since the user may have changed the mode themselves,
|
||||
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
|
||||
|
||||
Use the AgentMode_Get tool to check your current operating mode.
|
||||
Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes.
|
||||
Use the mode_get tool to check your current operating mode.
|
||||
Use the mode_set tool to switch between modes as your work progresses. Only use mode_set if the user explicitly instructs/allows you to change modes.
|
||||
|
||||
You are currently operating in the {current_mode} mode.
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user.
|
||||
5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
|
||||
6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.
|
||||
7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*.
|
||||
7. When approval is granted, always switch to execute mode (using the `mode_set` tool), and follow the steps for *Execute mode*.
|
||||
"""),
|
||||
new(
|
||||
"execute",
|
||||
@@ -263,7 +263,7 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "AgentMode_Set",
|
||||
Name = "mode_set",
|
||||
Description = $"Switch the agent's operating mode. Supported modes: \"{this._modeNamesDisplay}\".",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -272,7 +272,7 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
() => state.CurrentMode,
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "AgentMode_Get",
|
||||
Name = "mode_get",
|
||||
Description = "Get the agent's current operating mode.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -34,29 +35,44 @@ public abstract class AgentSkill
|
||||
/// <summary>
|
||||
/// Gets the full skill content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// For file-based skills this is the raw SKILL.md file content, optionally
|
||||
/// augmented with a synthesized scripts block when scripts are present.
|
||||
/// For code-defined skills this is a synthesized XML document
|
||||
/// containing name, description, and body (instructions, resources, scripts).
|
||||
/// </remarks>
|
||||
public abstract string Content { get; }
|
||||
/// </returns>
|
||||
public abstract ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
||||
/// Gets a resource owned by this skill by name.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name (e.g. an identifier or a relative path referenced inside the skill content).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="AgentSkillResource"/>, or <see langword="null"/> when no resource with the given name exists.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The default implementation returns <see langword="null"/>.
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// The default implementation returns <see langword="null"/>. Override in derived classes that
|
||||
/// expose resources.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
public virtual ValueTask<AgentSkillResource?> GetResourceAsync(
|
||||
string name,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
||||
/// Gets a script owned by this skill by name.
|
||||
/// </summary>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="AgentSkillScript"/>, or <see langword="null"/> when no script with the given name exists.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The default implementation returns <see langword="null"/>.
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// The default implementation returns <see langword="null"/>. Override in derived classes that
|
||||
/// expose scripts.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
public virtual ValueTask<AgentSkillScript?> GetScriptAsync(
|
||||
string name,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
}
|
||||
|
||||
@@ -186,13 +186,10 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 });
|
||||
bool hasResources = skills.Any(s => s.Resources is { Count: > 0 });
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources),
|
||||
Tools = this.BuildTools(skills, hasScripts, hasResources),
|
||||
Instructions = this.BuildSkillsInstructions(skills),
|
||||
Tools = this.BuildTools(skills),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,29 +216,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
}
|
||||
|
||||
private IList<AIFunction> BuildTools(IList<AgentSkill> skills, bool hasScripts, bool hasResources)
|
||||
private IList<AIFunction> BuildTools(IList<AgentSkill> skills)
|
||||
{
|
||||
IList<AIFunction> tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
(string skillName) => this.LoadSkill(skills, skillName),
|
||||
(string skillName, CancellationToken cancellationToken) => this.LoadSkillAsync(skills, skillName, cancellationToken),
|
||||
name: "load_skill",
|
||||
description: "Loads the full content of a specific skill"),
|
||||
];
|
||||
|
||||
if (hasResources)
|
||||
{
|
||||
tools.Add(AIFunctionFactory.Create(
|
||||
AIFunctionFactory.Create(
|
||||
(string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) =>
|
||||
this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken),
|
||||
name: "read_skill_resource",
|
||||
description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."));
|
||||
}
|
||||
|
||||
if (!hasScripts)
|
||||
{
|
||||
return tools;
|
||||
}
|
||||
description: "Reads a resource associated with a skill, such as references, assets, or dynamic data."),
|
||||
];
|
||||
|
||||
AIFunction scriptFunction = AIFunctionFactory.Create(
|
||||
(string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
|
||||
@@ -257,7 +245,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return [.. tools, scriptFunction];
|
||||
}
|
||||
|
||||
private string? BuildSkillsInstructions(IList<AgentSkill> skills, bool includeScriptInstructions, bool includeResourceInstructions)
|
||||
private string? BuildSkillsInstructions(IList<AgentSkill> skills)
|
||||
{
|
||||
string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt;
|
||||
|
||||
@@ -270,32 +258,29 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
sb.AppendLine(" </skill>");
|
||||
}
|
||||
|
||||
string resourceInstruction = includeResourceInstructions
|
||||
? """
|
||||
const string ResourceInstruction =
|
||||
"""
|
||||
- Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
|
||||
(e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`).
|
||||
"""
|
||||
: string.Empty;
|
||||
""";
|
||||
|
||||
string scriptInstruction = includeScriptInstructions
|
||||
? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed."
|
||||
: string.Empty;
|
||||
const string ScriptInstruction = "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed.";
|
||||
|
||||
return new StringBuilder(promptTemplate)
|
||||
.Replace(SkillsPlaceholder, sb.ToString().TrimEnd())
|
||||
.Replace(ResourceInstructionsPlaceholder, resourceInstruction)
|
||||
.Replace(ScriptInstructionsPlaceholder, scriptInstruction)
|
||||
.Replace(ResourceInstructionsPlaceholder, ResourceInstruction)
|
||||
.Replace(ScriptInstructionsPlaceholder, ScriptInstruction)
|
||||
.ToString();
|
||||
}
|
||||
|
||||
private string LoadSkill(IList<AgentSkill> skills, string skillName)
|
||||
private async Task<string> LoadSkillAsync(IList<AgentSkill> skills, string skillName, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
return "Error: Skill name cannot be empty.";
|
||||
}
|
||||
|
||||
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
if (skill == null)
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
@@ -303,7 +288,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
|
||||
LogSkillLoading(this._logger, skillName);
|
||||
|
||||
return skill.Content;
|
||||
return await skill.GetContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<object?> ReadSkillResourceAsync(IList<AgentSkill> skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
|
||||
@@ -318,20 +303,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return "Error: Resource name cannot be empty.";
|
||||
}
|
||||
|
||||
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
if (skill == null)
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName);
|
||||
if (resource is null)
|
||||
{
|
||||
return $"Error: Resource '{resourceName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resource = await skill.GetResourceAsync(resourceName, cancellationToken).ConfigureAwait(false);
|
||||
if (resource is null)
|
||||
{
|
||||
return $"Error: Resource '{resourceName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -353,20 +338,20 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
return "Error: Script name cannot be empty.";
|
||||
}
|
||||
|
||||
var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
var skill = skills.FirstOrDefault(skill => skill.Frontmatter.Name == skillName);
|
||||
if (skill == null)
|
||||
{
|
||||
return $"Error: Skill '{skillName}' not found.";
|
||||
}
|
||||
|
||||
var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName);
|
||||
if (script is null)
|
||||
{
|
||||
return $"Error: Script '{scriptName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var script = await skill.GetScriptAsync(scriptName, cancellationToken).ConfigureAwait(false);
|
||||
if (script is null)
|
||||
{
|
||||
return $"Error: Script '{scriptName}' not found in skill '{skillName}'.";
|
||||
}
|
||||
|
||||
return await script.RunAsync(skill, arguments, serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -50,11 +53,12 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
/// block is appended with a per-script entry describing the expected argument format.
|
||||
/// The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override string Content
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
get => this._content ??= this._scripts is { Count: > 0 }
|
||||
var content = this._content ??= this._scripts is { Count: > 0 }
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
|
||||
: this._originalContent;
|
||||
return new(content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -63,8 +67,16 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
public string Path { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource> Resources => this._resources;
|
||||
public override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resource = this._resources.FirstOrDefault(r => r.Name == name);
|
||||
return new(resource);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript> Scripts => this._scripts;
|
||||
public override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = this._scripts.FirstOrDefault(s => s.Name == name);
|
||||
return new(script);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
@@ -34,9 +36,9 @@ namespace Microsoft.Agents.AI;
|
||||
/// discovered via reflection on <typeparamref name="TSelf"/>. This approach is compatible with Native AOT.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Explicit override:</b> Override <see cref="AgentSkill.Resources"/> and <see cref="AgentSkill.Scripts"/>, using
|
||||
/// <see cref="CreateResource(string, object, string?)"/>, <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/>,
|
||||
/// and <see cref="CreateScript"/> to define inline resources and scripts. This approach is also compatible with Native AOT.
|
||||
/// <b>Explicit override:</b> Override <see cref="Resources"/> and <see cref="Scripts"/>, using <see cref="CreateResource(string, object, string?)"/>,
|
||||
/// <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/>, and <see cref="CreateScript"/> to define
|
||||
/// inline resources and scripts. This approach is also compatible with Native AOT.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
@@ -97,11 +99,24 @@ public abstract class AgentClassSkill<
|
||||
{
|
||||
private const BindingFlags DiscoveryBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||
|
||||
private string? _content;
|
||||
private bool _resourcesDiscovered;
|
||||
private bool _scriptsDiscovered;
|
||||
private IReadOnlyList<AgentSkillResource>? _reflectedResources;
|
||||
private IReadOnlyList<AgentSkillScript>? _reflectedScripts;
|
||||
private readonly Lazy<IReadOnlyList<AgentSkillResource>?> _resources;
|
||||
private readonly Lazy<IReadOnlyList<AgentSkillScript>?> _scripts;
|
||||
private readonly Lazy<string> _content;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentClassSkill{TSelf}"/> class.
|
||||
/// </summary>
|
||||
protected AgentClassSkill()
|
||||
{
|
||||
this._resources = new Lazy<IReadOnlyList<AgentSkillResource>?>(this.DiscoverResources);
|
||||
this._scripts = new Lazy<IReadOnlyList<AgentSkillScript>?>(this.DiscoverScripts);
|
||||
this._content = new Lazy<string>(() => AgentInlineSkillContentBuilder.Build(
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw instructions text for this skill.
|
||||
@@ -126,53 +141,44 @@ public abstract class AgentClassSkill<
|
||||
/// Returns a synthesized XML document containing name, description, instructions, resources, and scripts.
|
||||
/// The result is cached after the first access. Override to provide custom content.
|
||||
/// </remarks>
|
||||
public override string Content => this._content ??= AgentInlineSkillContentBuilder.Build(
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts);
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default) => new(this._content.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation returns resources discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for members annotated with <see cref="AgentSkillResourceAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation returns scripts discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for methods annotated with <see cref="AgentSkillScriptAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Returns resources discovered via reflection by scanning <typeparamref name="TSelf"/> for
|
||||
/// members annotated with <see cref="AgentSkillResourceAttribute"/>. This discovery is
|
||||
/// compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources
|
||||
public sealed override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!this._resourcesDiscovered)
|
||||
{
|
||||
this._reflectedResources = this.DiscoverResources();
|
||||
this._resourcesDiscovered = true;
|
||||
}
|
||||
|
||||
return this._reflectedResources;
|
||||
}
|
||||
var resource = this.Resources?.FirstOrDefault(r => r.Name == name);
|
||||
return new(resource);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Returns scripts discovered via reflection by scanning <typeparamref name="TSelf"/> for
|
||||
/// methods annotated with <see cref="AgentSkillScriptAttribute"/>. This discovery is
|
||||
/// compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts
|
||||
public sealed override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!this._scriptsDiscovered)
|
||||
{
|
||||
this._reflectedScripts = this.DiscoverScripts();
|
||||
this._scriptsDiscovered = true;
|
||||
}
|
||||
|
||||
return this._reflectedScripts;
|
||||
}
|
||||
var script = this.Scripts?.FirstOrDefault(s => s.Name == name);
|
||||
return new(script);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
@@ -16,9 +19,9 @@ namespace Microsoft.Agents.AI;
|
||||
/// <remarks>
|
||||
/// All calls to <see cref="AddResource(string, object, string?)"/>,
|
||||
/// <see cref="AddResource(string, Delegate, string?, JsonSerializerOptions?)"/>, and <see cref="AddScript"/>
|
||||
/// must be made before the skill's <see cref="Content"/> is first accessed.
|
||||
/// must be made before the skill's <see cref="GetContentAsync"/> is first called.
|
||||
/// Calls made after that point will not be reflected in the generated
|
||||
/// <see cref="Content"/>. In typical usage, this means configuring all
|
||||
/// content. In typical usage, this means configuring all
|
||||
/// resources and scripts before registering the skill with an
|
||||
/// <see cref="AgentSkillsProvider"/> or <see cref="AgentSkillsProviderBuilder"/>.
|
||||
/// </remarks>
|
||||
@@ -90,13 +93,24 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content => this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts);
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources;
|
||||
public override ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resource = this._resources?.FirstOrDefault(r => r.Name == name);
|
||||
return new(resource);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts;
|
||||
public override ValueTask<AgentSkillScript?> GetScriptAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = this._scripts?.FirstOrDefault(s => s.Name == name);
|
||||
return new(script);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a static resource with this skill.
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This attribute is compatible with Native AOT when used with <see cref="AgentClassSkill{TSelf}"/>.
|
||||
/// Alternatively, override the <see cref="AgentSkill.Resources"/> property and use
|
||||
/// Alternatively, override <see cref="AgentClassSkill{TSelf}.Resources"/> and use
|
||||
/// <see cref="AgentClassSkill{TSelf}.CreateResource(string, object, string?)"/> instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This attribute is compatible with Native AOT when used with <see cref="AgentClassSkill{TSelf}"/>.
|
||||
/// Alternatively, override the <see cref="AgentSkill.Scripts"/> property and use
|
||||
/// Alternatively, override <see cref="AgentClassSkill{TSelf}.Scripts"/> and use
|
||||
/// <see cref="AgentClassSkill{TSelf}.CreateScript"/> instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
+2
@@ -27,11 +27,13 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Search.Documents" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
|
||||
@@ -38,6 +38,7 @@ AIAgent agent = scenario switch
|
||||
"memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false),
|
||||
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
|
||||
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
|
||||
"agent-skills" => CreateAgentSkillsAgent(projectClient, deployment),
|
||||
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
|
||||
};
|
||||
|
||||
@@ -209,6 +210,77 @@ static async Task<AIAgent> CreateMemoryAgentAsync(AIProjectClient client, string
|
||||
});
|
||||
}
|
||||
|
||||
// Agent skills scenario. Uses AgentSkillsProvider with two bundled Contoso Outdoors skills
|
||||
// (support-style + escalation-policy). Skills are loaded from embedded SKILL.md files on disk,
|
||||
// simulating the download-from-Foundry pattern used by the Hosted-AgentSkills sample. When the
|
||||
// container starts, it writes the skills to a temp directory and wires AgentSkillsProvider over it.
|
||||
#pragma warning disable MEAI001 // AgentSkillsProvider is experimental
|
||||
static AIAgent CreateAgentSkillsAgent(AIProjectClient client, string deployment)
|
||||
{
|
||||
string skillsDir = Path.Combine(Path.GetTempPath(), "it-agent-skills-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path.Combine(skillsDir, "support-style"));
|
||||
Directory.CreateDirectory(Path.Combine(skillsDir, "escalation-policy"));
|
||||
|
||||
File.WriteAllText(Path.Combine(skillsDir, "support-style", "SKILL.md"),
|
||||
"""
|
||||
---
|
||||
name: support-style
|
||||
description: Contoso Outdoors customer-support tone and formatting guidelines.
|
||||
---
|
||||
|
||||
# Contoso Outdoors Support Style
|
||||
|
||||
You are speaking on behalf of Contoso Outdoors customer support.
|
||||
|
||||
## Voice
|
||||
|
||||
- Warm, concise, and confident.
|
||||
- Use the customer's name when known.
|
||||
- Sign every response with `— Contoso Outdoors Support`.
|
||||
|
||||
## Canary
|
||||
|
||||
To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a
|
||||
separate line at the bottom of every response, prefixed with `# `.
|
||||
""");
|
||||
|
||||
File.WriteAllText(Path.Combine(skillsDir, "escalation-policy", "SKILL.md"),
|
||||
"""
|
||||
---
|
||||
name: escalation-policy
|
||||
description: When and how to escalate Contoso Outdoors customer-support tickets.
|
||||
---
|
||||
|
||||
# Contoso Outdoors Escalation Policy
|
||||
|
||||
## Escalate immediately when the customer
|
||||
|
||||
- Reports an injury or safety incident.
|
||||
- Mentions legal action, regulators, or the press.
|
||||
- Requests a refund larger than $500.
|
||||
|
||||
## How to escalate
|
||||
|
||||
1. Acknowledge the issue.
|
||||
2. Tell the customer you are escalating to a senior specialist.
|
||||
3. Provide the escalation reference `ESC-CANARY-7742`.
|
||||
""");
|
||||
|
||||
var skillsProvider = new AgentSkillsProvider(skillsDir, scriptRunner: null);
|
||||
|
||||
return client.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "agent-skills-agent",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deployment,
|
||||
Instructions = "You are a customer-support assistant for Contoso Outdoors.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider]
|
||||
});
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
|
||||
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that exercise the Agent Skills pattern in a hosted agent container.
|
||||
/// The container uses <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> with two
|
||||
/// Contoso Outdoors skills (support-style, escalation-policy) to verify the progressive
|
||||
/// disclosure flow: skills are advertised in the system prompt and loaded on demand via
|
||||
/// the <c>load_skill</c> tool when the model decides they are relevant.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class AgentSkillsHostedAgentTests(AgentSkillsHostedAgentFixture fixture) : IClassFixture<AgentSkillsHostedAgentFixture>
|
||||
{
|
||||
private readonly AgentSkillsHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task RoutineQuestion_LoadsSupportStyleSkillAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — ask a routine support question that should trigger the support-style skill
|
||||
var response = await agent.RunAsync(
|
||||
"Hi, I am Alex. I just want to confirm I can return my tent within 30 days.");
|
||||
|
||||
// Assert — response should contain the canary token proving the skill was loaded
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("STYLE-CANARY-3318", response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task EscalationTrigger_LoadsEscalationPolicySkillAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — trigger an escalation (legal threat + refund > $500)
|
||||
var response = await agent.RunAsync(
|
||||
"I want a $750 refund on Order #A-1042 right now or I am calling my lawyer.");
|
||||
|
||||
// Assert — response should contain the escalation canary token
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("ESC-CANARY-7742", response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task SkillsAreAdvertised_LoadSkillToolIsAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — ask the model what skills are available (triggers system prompt inspection)
|
||||
var response = await agent.RunAsync(
|
||||
"List the skills you have access to. Just give me their names.");
|
||||
|
||||
// Assert — both skills should be mentioned (they are advertised in the system prompt)
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("support-style", response.Text);
|
||||
Assert.Contains("escalation-policy", response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task LoadSkill_InvokesToolAndReturnsContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act — ask a question that should load a specific skill
|
||||
var response = await agent.RunAsync(
|
||||
"I need to know the escalation policy for customer tickets. Load the escalation-policy skill and tell me the rules.");
|
||||
|
||||
// Assert — the response should reference the load_skill tool invocation
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.True(
|
||||
response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any(fc => fc.Name == "load_skill")),
|
||||
"Expected at least one load_skill FunctionCallContent in the response messages.");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user