mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0f5fa541c | ||
|
|
e3290a2d22 | ||
|
|
200488cb08 | ||
|
|
bd4fc64b4d | ||
|
|
b2e77067e9 | ||
|
|
08541ee5a9 | ||
|
|
dc4bafbc1e | ||
|
|
de6d0267f2 | ||
|
|
0099a6e2fa | ||
|
|
793403f3db | ||
|
|
9fdd7429a8 | ||
|
|
abc9b60ec9 | ||
|
|
6bc0dc5911 | ||
|
|
cf91819625 | ||
|
|
578416a379 |
@@ -212,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" />
|
||||
@@ -281,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" />
|
||||
@@ -357,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" />
|
||||
@@ -601,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" />
|
||||
@@ -654,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.2</VersionPrefix>
|
||||
<VersionPrefix>1.7.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260521</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.2</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!;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+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,
|
||||
};
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=agent-skills</c> mode.
|
||||
/// The container creates two Contoso Outdoors skills (support-style, escalation-policy) on disk
|
||||
/// and wires them into <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> so the model can
|
||||
/// discover and load skills via the progressive disclosure pattern.
|
||||
/// </summary>
|
||||
public sealed class AgentSkillsHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "agent-skills";
|
||||
}
|
||||
@@ -199,6 +199,7 @@ human-only operation; CI only adds and deletes versions under existing agents.
|
||||
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
|
||||
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
|
||||
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
|
||||
| `AgentSkillsHostedAgentFixture` | `agent-skills` | `it-agent-skills` | Agent skills via `AgentSkillsProvider`: advertises two Contoso Outdoors skills (support-style, escalation-policy) in the system prompt, loads them on demand via `load_skill`, verifies canary tokens prove the skill was loaded. |
|
||||
|
||||
The placeholder scenarios will be wired up in the test container `Program.cs` once the
|
||||
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
|
||||
|
||||
@@ -47,7 +47,8 @@ $Scenarios = @(
|
||||
'custom-storage',
|
||||
'memory',
|
||||
'azure-search-rag',
|
||||
'session-files'
|
||||
'session-files',
|
||||
'agent-skills'
|
||||
)
|
||||
|
||||
# Resolve project ARM scope from the endpoint.
|
||||
|
||||
@@ -1124,6 +1124,7 @@ public sealed class A2AAgentTests : IDisposable
|
||||
Assert.Equal(TaskId, update0.ResponseId);
|
||||
Assert.Equal(this._agent.Id, update0.AgentId);
|
||||
Assert.Null(update0.FinishReason);
|
||||
Assert.Null(update0.MessageId);
|
||||
Assert.IsType<TaskStatusUpdateEvent>(update0.RawRepresentation);
|
||||
|
||||
// Assert - session should be updated with context and task IDs
|
||||
@@ -1132,6 +1133,50 @@ public sealed class A2AAgentTests : IDisposable
|
||||
Assert.Equal(TaskId, a2aSession.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithTaskStatusUpdateEventAndMessageId_YieldsMessageIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string TaskId = "task-status-msg-123";
|
||||
const string ContextId = "ctx-status-msg-456";
|
||||
const string ExpectedMessageId = "msg-status-789";
|
||||
|
||||
this._handler.StreamingResponseToReturn = new StreamResponse
|
||||
{
|
||||
StatusUpdate = new TaskStatusUpdateEvent
|
||||
{
|
||||
TaskId = TaskId,
|
||||
ContextId = ContextId,
|
||||
Status = new()
|
||||
{
|
||||
State = TaskState.Working,
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = ExpectedMessageId,
|
||||
Parts = [Part.FromText("Processing your request...")]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var session = await this._agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in this._agent.RunStreamingAsync("Check task status", session))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(updates);
|
||||
|
||||
var update0 = updates[0];
|
||||
Assert.Equal(ExpectedMessageId, update0.MessageId);
|
||||
Assert.Equal(TaskId, update0.ResponseId);
|
||||
Assert.IsType<TaskStatusUpdateEvent>(update0.RawRepresentation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithInputRequiredStatusUpdate_YieldsStatusContentsAsync()
|
||||
{
|
||||
@@ -1150,6 +1195,7 @@ public sealed class A2AAgentTests : IDisposable
|
||||
State = TaskState.InputRequired,
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "input-msg-789",
|
||||
Parts = [Part.FromText("Where would you like to fly?")]
|
||||
}
|
||||
}
|
||||
@@ -1170,6 +1216,7 @@ public sealed class A2AAgentTests : IDisposable
|
||||
|
||||
var update0 = updates[0];
|
||||
Assert.Equal(TaskId, update0.ResponseId);
|
||||
Assert.Equal("input-msg-789", update0.MessageId);
|
||||
Assert.Null(update0.FinishReason);
|
||||
|
||||
var textContent = Assert.Single(update0.Contents.OfType<TextContent>());
|
||||
|
||||
@@ -914,4 +914,147 @@ public sealed class AGUIChatMessageExtensionsTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consecutive Assistant-Tool-Call Coalescing
|
||||
|
||||
/// <summary>
|
||||
/// Bug #3 reproduction: consecutive AGUIAssistantMessages with ToolCalls should
|
||||
/// be coalesced into a single ChatMessage with multiple FunctionCallContent
|
||||
/// entries. Without coalescing, Azure OpenAI rejects the history with HTTP 400.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsChatMessages_ConsecutiveAssistantToolCallMessages_CoalesceIntoOneChatMessage()
|
||||
{
|
||||
// Arrange — 3 consecutive assistant messages with tool calls (no intervening tool msg)
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIUserMessage { Id = "user-1", Content = "Run 3 queries" },
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-1",
|
||||
Content = "",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_A", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"1\"}" } }
|
||||
]
|
||||
},
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-2",
|
||||
Content = "",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_B", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"2\"}" } }
|
||||
]
|
||||
},
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-3",
|
||||
Content = "",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_C", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"3\"}" } }
|
||||
]
|
||||
},
|
||||
new AGUIToolMessage { Id = "tool-1", ToolCallId = "call_A", Content = "\"result1\"" },
|
||||
new AGUIToolMessage { Id = "tool-2", ToolCallId = "call_B", Content = "\"result2\"" },
|
||||
new AGUIToolMessage { Id = "tool-3", ToolCallId = "call_C", Content = "\"result3\"" },
|
||||
new AGUIUserMessage { Id = "user-2", Content = "Run it again" },
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert — the 3 consecutive assistant-tool-call messages should coalesce into 1
|
||||
List<ChatMessage> assistantWithToolCalls = chatMessages
|
||||
.Where(m => m.Role == ChatRole.Assistant && m.Contents.OfType<FunctionCallContent>().Any())
|
||||
.ToList();
|
||||
|
||||
Assert.Single(assistantWithToolCalls);
|
||||
|
||||
// The single coalesced message should contain all 3 FunctionCallContent entries
|
||||
List<FunctionCallContent> functionCalls = assistantWithToolCalls[0].Contents
|
||||
.OfType<FunctionCallContent>().ToList();
|
||||
Assert.Equal(3, functionCalls.Count);
|
||||
Assert.Equal("call_A", functionCalls[0].CallId);
|
||||
Assert.Equal("call_B", functionCalls[1].CallId);
|
||||
Assert.Equal("call_C", functionCalls[2].CallId);
|
||||
|
||||
// MessageId should be from the first message in the coalesced group
|
||||
Assert.Equal("asst-1", assistantWithToolCalls[0].MessageId);
|
||||
|
||||
// Total messages: user + coalesced assistant + 3 tools + user = 6
|
||||
Assert.Equal(6, chatMessages.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single assistant message with tool calls (not consecutive) should still
|
||||
/// produce one ChatMessage — no behavior change from coalescing logic.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsChatMessages_SingleAssistantToolCallMessage_ProducesOneChatMessage()
|
||||
{
|
||||
// Arrange
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-1",
|
||||
Content = "Here are the results",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall { Id = "call_A", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{}" } },
|
||||
new AGUIToolCall { Id = "call_B", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{}" } },
|
||||
]
|
||||
},
|
||||
new AGUIToolMessage { Id = "tool-1", ToolCallId = "call_A", Content = "\"r1\"" },
|
||||
new AGUIToolMessage { Id = "tool-2", ToolCallId = "call_B", Content = "\"r2\"" },
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert — single assistant message, not coalesced from multiple
|
||||
Assert.Equal(3, chatMessages.Count);
|
||||
Assert.Equal(ChatRole.Assistant, chatMessages[0].Role);
|
||||
List<FunctionCallContent> calls = chatMessages[0].Contents.OfType<FunctionCallContent>().ToList();
|
||||
Assert.Equal(2, calls.Count);
|
||||
Assert.Equal("asst-1", chatMessages[0].MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When consecutive assistant-tool-call messages are at the END of the stream
|
||||
/// (no subsequent non-tool-call message to trigger flush), they should still
|
||||
/// be coalesced and flushed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsChatMessages_ConsecutiveAssistantToolCallsAtEndOfStream_FlushesCorrectly()
|
||||
{
|
||||
// Arrange — stream ends with consecutive assistant tool-call messages
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIUserMessage { Id = "user-1", Content = "Do things" },
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-1",
|
||||
ToolCalls = [new AGUIToolCall { Id = "call_X", Type = "function", Function = new AGUIFunctionCall { Name = "fn", Arguments = "{}" } }]
|
||||
},
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "asst-2",
|
||||
ToolCalls = [new AGUIToolCall { Id = "call_Y", Type = "function", Function = new AGUIFunctionCall { Name = "fn", Arguments = "{}" } }]
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert — should be user + 1 coalesced assistant = 2 messages
|
||||
Assert.Equal(2, chatMessages.Count);
|
||||
Assert.Equal(ChatRole.User, chatMessages[0].Role);
|
||||
Assert.Equal(ChatRole.Assistant, chatMessages[1].Role);
|
||||
Assert.Equal(2, chatMessages[1].Contents.OfType<FunctionCallContent>().Count());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -109,11 +109,13 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When ChatResponseUpdate has empty string MessageId, the AGUI layer generates
|
||||
/// a fallback so ToolCallStartEvent.ParentMessageId is valid.
|
||||
/// When ChatResponseUpdate has empty string MessageId, the AGUI layer passes
|
||||
/// through the raw provider value for ToolCallStartEvent.ParentMessageId.
|
||||
/// Tool-call chunks should NOT receive the text-event fallback GUID — that
|
||||
/// would collapse parallel tool calls into one assistant message in the FE.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ToolCalls_EmptyMessageId_GeneratesFallbackParentMessageIdAsync()
|
||||
public async Task ToolCalls_EmptyMessageId_DoesNotGenerateFallbackParentMessageIdAsync()
|
||||
{
|
||||
// Arrange - ChatResponseUpdate with a tool call but empty MessageId
|
||||
FunctionCallContent functionCall = new("call_abc123", "GetWeather")
|
||||
@@ -139,14 +141,14 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — ParentMessageId should have a generated fallback
|
||||
// Assert — ParentMessageId should be empty (raw provider value, no synthetic fallback)
|
||||
ToolCallStartEvent? toolCallStart = aguiEvents.OfType<ToolCallStartEvent>().FirstOrDefault();
|
||||
Assert.NotNull(toolCallStart);
|
||||
Assert.Equal("call_abc123", toolCallStart.ToolCallId);
|
||||
Assert.Equal("GetWeather", toolCallStart.ToolCallName);
|
||||
Assert.False(
|
||||
Assert.True(
|
||||
string.IsNullOrEmpty(toolCallStart.ParentMessageId),
|
||||
"ParentMessageId should have a generated fallback for empty provider MessageId");
|
||||
"ParentMessageId should be empty when provider omits MessageId (raw pass-through)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -183,10 +185,13 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
|
||||
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
|
||||
|
||||
Assert.Equal(textStart.MessageId, toolCallStart.ParentMessageId);
|
||||
// Tool-call ParentMessageId should NOT leak the text fallback GUID
|
||||
Assert.NotEqual(textStart.MessageId, toolCallStart.ParentMessageId);
|
||||
Assert.Equal("call_abc123", toolCallResult.ToolCallId);
|
||||
Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId));
|
||||
Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId);
|
||||
// Result MessageId should be deterministic based on CallId
|
||||
Assert.Equal("result-call_abc123", toolCallResult.MessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -230,10 +235,11 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType<ToolCallStartEvent>());
|
||||
ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType<ToolCallResultEvent>());
|
||||
|
||||
Assert.Equal(textStarts[0].MessageId, toolCallStart.ParentMessageId);
|
||||
// Tool-call ParentMessageId should NOT leak the text fallback GUID
|
||||
Assert.NotEqual(textStarts[0].MessageId, toolCallStart.ParentMessageId);
|
||||
Assert.NotEqual(textStarts[0].MessageId, toolCallResult.MessageId);
|
||||
Assert.Equal(toolCallResult.MessageId, toolText.MessageId);
|
||||
Assert.Equal(textStarts[^1].MessageId, toolCallResult.MessageId);
|
||||
// Result MessageId should be deterministic based on CallId
|
||||
Assert.Equal("result-call_abc123", toolCallResult.MessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -274,6 +280,86 @@ public sealed class AGUIStreamingMessageIdTests
|
||||
Assert.Equal(2, contentEvents.Count);
|
||||
Assert.All(contentEvents, e => Assert.Equal("chatcmpl-abc123", e.MessageId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bug #1 reproduction: parallel tool calls with empty MessageId should NOT all
|
||||
/// share the same synthetic ParentMessageId. Each should pass through the raw
|
||||
/// provider value (empty), allowing the FE to render them as distinct cards.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ParallelToolCalls_EmptyMessageId_DoNotShareParentMessageIdAsync()
|
||||
{
|
||||
// Arrange — 3 parallel tool calls with empty MessageId (real OpenAI behavior)
|
||||
List<ChatResponseUpdate> providerUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Let me run those queries.") { MessageId = "chatcmpl-real" },
|
||||
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_A", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "1" } }] },
|
||||
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_B", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "2" } }] },
|
||||
new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_C", "query") { Arguments = new Dictionary<string, object?> { ["q"] = "3" } }] },
|
||||
];
|
||||
|
||||
// Act
|
||||
List<BaseEvent> aguiEvents = [];
|
||||
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
|
||||
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — all 3 tool calls should have empty ParentMessageId (raw provider value),
|
||||
// NOT the text fallback GUID
|
||||
List<ToolCallStartEvent> toolCallStarts = aguiEvents.OfType<ToolCallStartEvent>().ToList();
|
||||
Assert.Equal(3, toolCallStarts.Count);
|
||||
Assert.All(toolCallStarts, tc => Assert.True(string.IsNullOrEmpty(tc.ParentMessageId)));
|
||||
|
||||
// Text events should still have a valid fallback MessageId
|
||||
TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType<TextMessageStartEvent>());
|
||||
Assert.False(string.IsNullOrEmpty(textStart.MessageId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bug #2 reproduction: tool results batched into one ChatResponseUpdate with a
|
||||
/// shared MEAI MessageId should each get a unique deterministic MessageId.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ToolCallResults_SharedMeaiMessageId_HaveUniqueMessageIdsPerCallAsync()
|
||||
{
|
||||
// Arrange — MEAI batches all FunctionResultContent into one update with shared id
|
||||
List<ChatResponseUpdate> providerUpdates =
|
||||
[
|
||||
new ChatResponseUpdate
|
||||
{
|
||||
Role = ChatRole.Tool,
|
||||
MessageId = "meai-shared-id",
|
||||
Contents =
|
||||
[
|
||||
new FunctionResultContent("call_A", "result1"),
|
||||
new FunctionResultContent("call_B", "result2"),
|
||||
new FunctionResultContent("call_C", "result3"),
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
List<BaseEvent> aguiEvents = [];
|
||||
await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync()
|
||||
.AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
aguiEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert — each result should have a unique MessageId
|
||||
List<ToolCallResultEvent> toolResults = aguiEvents.OfType<ToolCallResultEvent>().ToList();
|
||||
Assert.Equal(3, toolResults.Count);
|
||||
|
||||
string?[] distinctIds = toolResults.Select(r => r.MessageId).Distinct().ToArray();
|
||||
Assert.Equal(3, distinctIds.Length);
|
||||
|
||||
// Verify deterministic format
|
||||
Assert.Equal("result-call_A", toolResults[0].MessageId);
|
||||
Assert.Equal("result-call_B", toolResults[1].MessageId);
|
||||
Assert.Equal("result-call_C", toolResults[2].MessageId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal empty <see cref="IServiceProvider"/> for in-memory fixtures that don't use DI.
|
||||
/// </summary>
|
||||
internal sealed class EmptyServiceProvider : IServiceProvider
|
||||
{
|
||||
public static EmptyServiceProvider Instance { get; } = new();
|
||||
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipelines;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// In-process MCP server fixture that pairs a <see cref="McpServer"/> and a <see cref="McpClient"/>
|
||||
/// over duplex <see cref="Pipe"/>-backed streams so unit tests can exercise the
|
||||
/// real task-augmentation protocol without spawning a child process or opening a socket.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryMcpServerFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly McpServer _server;
|
||||
private readonly Task _serverLoop;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
public McpClient Client { get; }
|
||||
|
||||
private InMemoryMcpServerFixture(McpServer server, McpClient client, Task serverLoop, CancellationTokenSource cts)
|
||||
{
|
||||
this._server = server;
|
||||
this.Client = client;
|
||||
this._serverLoop = serverLoop;
|
||||
this._cts = cts;
|
||||
}
|
||||
|
||||
public static async Task<InMemoryMcpServerFixture> CreateAsync(
|
||||
McpServerPrimitiveCollection<McpServerTool> tools,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Pipe clientToServer = new();
|
||||
Pipe serverToClient = new();
|
||||
|
||||
// Stream conventions:
|
||||
// StreamClientTransport(serverInput, serverOutput, ...): serverInput is what the client
|
||||
// WRITES to (server reads it); serverOutput is what the client READS from (server writes it).
|
||||
// StreamServerTransport(input, output, ...): input is what the server READS from; output
|
||||
// is what the server WRITES to.
|
||||
Stream clientWriteStream = clientToServer.Writer.AsStream();
|
||||
Stream clientReadStream = serverToClient.Reader.AsStream();
|
||||
Stream serverReadStream = clientToServer.Reader.AsStream();
|
||||
Stream serverWriteStream = serverToClient.Writer.AsStream();
|
||||
|
||||
StreamServerTransport serverTransport = new(
|
||||
serverReadStream,
|
||||
serverWriteStream,
|
||||
"test-server",
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
McpServerOptions serverOptions = new()
|
||||
{
|
||||
ServerInfo = new Implementation { Name = "test-server", Version = "1.0.0" },
|
||||
TaskStore = new InMemoryMcpTaskStore(),
|
||||
ToolCollection = tools,
|
||||
};
|
||||
|
||||
McpServer server = McpServer.Create(
|
||||
serverTransport,
|
||||
serverOptions,
|
||||
NullLoggerFactory.Instance,
|
||||
EmptyServiceProvider.Instance);
|
||||
|
||||
CancellationTokenSource cts = new();
|
||||
Task serverLoop = Task.Run(() => server.RunAsync(cts.Token), cts.Token);
|
||||
|
||||
StreamClientTransport clientTransport = new(
|
||||
clientWriteStream,
|
||||
clientReadStream,
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
McpClient client = await McpClient.CreateAsync(
|
||||
clientTransport,
|
||||
clientOptions: null,
|
||||
NullLoggerFactory.Instance,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new InMemoryMcpServerFixture(server, client, serverLoop, cts);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.Client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort.
|
||||
}
|
||||
|
||||
this._cts.Cancel();
|
||||
|
||||
try
|
||||
{
|
||||
await this._serverLoop.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected.
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await this._server.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort.
|
||||
}
|
||||
|
||||
this._cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||
|
||||
public class ListAgentToolsWithTaskSupportTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ListAgentToolsWithTaskSupport_WrapsTaskCapableTools_LeavesOthersAsIsAsync()
|
||||
{
|
||||
// Arrange
|
||||
McpServerPrimitiveCollection<McpServerTool> tools = [
|
||||
TestTools.Create("opt", ToolTaskSupport.Optional, () => "opt-result"),
|
||||
TestTools.Create("req", ToolTaskSupport.Required, () => "req-result"),
|
||||
TestTools.Create("forb", ToolTaskSupport.Forbidden, () => "forb-result"),
|
||||
TestTools.Create("none", taskSupport: null, () => "none-result"),
|
||||
];
|
||||
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
|
||||
|
||||
// Act
|
||||
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(4);
|
||||
AIFunction opt = result.Single(f => f.Name == "opt");
|
||||
AIFunction req = result.Single(f => f.Name == "req");
|
||||
AIFunction forb = result.Single(f => f.Name == "forb");
|
||||
AIFunction none = result.Single(f => f.Name == "none");
|
||||
|
||||
req.Should().BeOfType<TaskAwareMcpClientAIFunction>("Required tools must be wrapped");
|
||||
opt.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Optional tools must not be wrapped; inline invocation is preserved by default");
|
||||
forb.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Forbidden tools must not be wrapped");
|
||||
none.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Tools without execution metadata must not be wrapped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAgentToolsWithTaskSupport_ThrowsOnNullClientAsync()
|
||||
{
|
||||
// Arrange
|
||||
ModelContextProtocol.Client.McpClient client = null!;
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await client.ListAgentToolsWithTaskSupportAsync();
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||
|
||||
public class McpTaskOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_AreSane()
|
||||
{
|
||||
// Act
|
||||
McpTaskOptions options = new();
|
||||
|
||||
// Assert
|
||||
options.DefaultTimeToLive.Should().BeNull();
|
||||
options.CancelRemoteTaskOnLocalCancellation.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||
|
||||
public class TaskAwareMcpClientAIFunctionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RequiredTool_HappyPath_ReturnsResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
McpServerPrimitiveCollection<McpServerTool> tools = [
|
||||
TestTools.Create("req", ToolTaskSupport.Required, () => "required-result"),
|
||||
];
|
||||
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
|
||||
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
|
||||
AIFunction req = result.Single(f => f.Name == "req");
|
||||
req.Should().BeOfType<TaskAwareMcpClientAIFunction>();
|
||||
|
||||
// Act
|
||||
object? invokeResult = await req.InvokeAsync(arguments: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
JsonElement payload = invokeResult.Should().BeOfType<JsonElement>().Subject;
|
||||
ExtractTextContent(payload).Should().Be("required-result");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_PropagatesDefaultTimeToLiveAsync()
|
||||
{
|
||||
// Arrange — capture the request meta on the server so we can assert TTL flowed through.
|
||||
TimeSpan? observedTtl = null;
|
||||
McpServerTool tool = McpServerTool.Create(
|
||||
(RequestContext<CallToolRequestParams> ctx) =>
|
||||
{
|
||||
observedTtl = ctx.Params?.Task?.TimeToLive;
|
||||
return "ok";
|
||||
},
|
||||
new McpServerToolCreateOptions
|
||||
{
|
||||
Name = "ttl-tool",
|
||||
Description = "Echoes the requested TTL.",
|
||||
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
|
||||
});
|
||||
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
|
||||
|
||||
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
|
||||
|
||||
TimeSpan requestedTtl = TimeSpan.FromMinutes(7);
|
||||
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync(new McpTaskOptions { DefaultTimeToLive = requestedTtl });
|
||||
AIFunction wrapped = result.Single();
|
||||
|
||||
// Act
|
||||
_ = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
observedTtl.Should().Be(requestedTtl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RespectsCancellationAsync()
|
||||
{
|
||||
// Arrange — a tool that never completes until it's cancelled.
|
||||
var serverCancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
McpServerTool tool = McpServerTool.Create(
|
||||
async (CancellationToken ct) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
serverCancelled.TrySetResult(true);
|
||||
throw;
|
||||
}
|
||||
|
||||
return "should-not-complete";
|
||||
},
|
||||
new McpServerToolCreateOptions
|
||||
{
|
||||
Name = "blocking",
|
||||
Description = "Blocks indefinitely until cancelled.",
|
||||
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
|
||||
});
|
||||
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
|
||||
|
||||
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
|
||||
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
|
||||
AIFunction wrapped = result.Single();
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
// Act — start the invocation, cancel after a brief delay.
|
||||
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, cts.Token).AsTask();
|
||||
await Task.Delay(200);
|
||||
cts.Cancel();
|
||||
|
||||
// Assert — wrapper observes cancellation and signals server-side cancellation.
|
||||
Func<Task> awaitInvocation = async () => await invocation;
|
||||
await awaitInvocation.Should().ThrowAsync<OperationCanceledException>();
|
||||
|
||||
// Server-side handler should have observed cancellation as a result of the wrapper's
|
||||
// tasks/cancel call (best-effort wait — give the server-loop a few seconds).
|
||||
Task observedTask = serverCancelled.Task;
|
||||
Task completed = await Task.WhenAny(observedTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
completed.Should().BeSameAs(observedTask, "the wrapper should have issued tasks/cancel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_FailedTask_ThrowsInvalidOperationAsync()
|
||||
{
|
||||
// Arrange — a tool whose handler throws, which the server surfaces as a Failed task.
|
||||
McpServerTool tool = McpServerTool.Create(
|
||||
(Func<string>)(() => throw new InvalidOperationException("simulated tool failure")),
|
||||
new McpServerToolCreateOptions
|
||||
{
|
||||
Name = "boom",
|
||||
Description = "Throws unconditionally.",
|
||||
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
|
||||
});
|
||||
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
|
||||
|
||||
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
|
||||
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
|
||||
AIFunction wrapped = result.Single();
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
|
||||
|
||||
// Assert — Phase 1 surfaces non-Completed terminal states as InvalidOperationException
|
||||
// carrying the server's StatusMessage. (See PollAndRetrieveResultAsync.)
|
||||
await act.Should().ThrowAsync<Exception>().Where(ex =>
|
||||
ex is InvalidOperationException
|
||||
|| ex.GetType().FullName == "ModelContextProtocol.McpException");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the first text-content block from a serialized <c>CallToolResult</c>
|
||||
/// (the JSON shape returned by the wrapper and by <c>McpClientTool.InvokeAsync</c>).
|
||||
/// </summary>
|
||||
private static string ExtractTextContent(JsonElement payload)
|
||||
{
|
||||
payload.ValueKind.Should().Be(JsonValueKind.Object);
|
||||
JsonElement content = payload.GetProperty("content");
|
||||
content.ValueKind.Should().Be(JsonValueKind.Array);
|
||||
JsonElement firstBlock = content.EnumerateArray().First();
|
||||
return firstBlock.GetProperty("text").GetString()!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mcp.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers to create <see cref="McpServerTool"/> instances with a specific
|
||||
/// <see cref="ToolTaskSupport"/> level for in-memory fixtures.
|
||||
/// </summary>
|
||||
internal static class TestTools
|
||||
{
|
||||
public static McpServerTool Create(string name, ToolTaskSupport? taskSupport, Delegate handler)
|
||||
{
|
||||
McpServerToolCreateOptions options = new()
|
||||
{
|
||||
Name = name,
|
||||
Description = $"Test tool {name}.",
|
||||
};
|
||||
|
||||
if (taskSupport is ToolTaskSupport ts)
|
||||
{
|
||||
options.Execution = new ToolExecution { TaskSupport = ts };
|
||||
}
|
||||
|
||||
return McpServerTool.Create(handler, options);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
public sealed class AgentClassSkillTests
|
||||
{
|
||||
[Fact]
|
||||
public void MinimalClassSkill_HasNullOverrides_AndSynthesizesContent()
|
||||
public async Task MinimalClassSkill_HasNullOverrides_AndSynthesizesContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new MinimalClassSkill();
|
||||
@@ -26,18 +26,17 @@ public sealed class AgentClassSkillTests
|
||||
// Act & Assert — null overrides
|
||||
Assert.Equal("minimal", skill.Frontmatter.Name);
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.Scripts);
|
||||
|
||||
// Act & Assert — synthesized XML content
|
||||
Assert.Contains("<name>minimal</name>", skill.Content);
|
||||
Assert.Contains("<description>A minimal skill.</description>", skill.Content);
|
||||
Assert.Contains("<instructions>", skill.Content);
|
||||
Assert.Contains("Minimal skill body.", skill.Content);
|
||||
Assert.Contains("</instructions>", skill.Content);
|
||||
Assert.Contains("<name>minimal</name>", await skill.GetContentAsync());
|
||||
Assert.Contains("<description>A minimal skill.</description>", await skill.GetContentAsync());
|
||||
Assert.Contains("<instructions>", await skill.GetContentAsync());
|
||||
Assert.Contains("Minimal skill body.", await skill.GetContentAsync());
|
||||
Assert.Contains("</instructions>", await skill.GetContentAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullClassSkill_ReturnsOverriddenLists_AndCachesContent()
|
||||
public async Task FullClassSkill_ReturnsOverriddenLists_AndCachesContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
@@ -50,11 +49,11 @@ public sealed class AgentClassSkillTests
|
||||
Assert.Equal("TestScript", skill.Scripts![0].Name);
|
||||
|
||||
// Act & Assert — Content is cached
|
||||
Assert.Same(skill.Content, skill.Content);
|
||||
Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — Content includes parameter schema from typed script
|
||||
Assert.Contains("parameters_schema", skill.Content);
|
||||
Assert.Contains("value", skill.Content);
|
||||
Assert.Contains("parameters_schema", await skill.GetContentAsync());
|
||||
Assert.Contains("value", await skill.GetContentAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -117,6 +116,116 @@ public sealed class AgentClassSkillTests
|
||||
Assert.Single(scriptOnly.Scripts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("test-resource");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resource);
|
||||
Assert.Equal("test-resource", resource!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NoResources_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new MinimalClassSkill();
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("anything");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("TestScript");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("TestScript", script!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new FullClassSkill();
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NoScripts_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new MinimalClassSkill();
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("anything");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentAccess_ToReflectedResourcesScriptsAndContent_InvokesDiscoveryOnceAsync()
|
||||
{
|
||||
// Regression test for thread-safety of Lazy<T> initialization in AgentClassSkill<TSelf>.
|
||||
// AttributedFullSkill uses attribute-based discovery (no override), so it exercises
|
||||
// the base class's Lazy<T> fields rather than a subclass's own caching.
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
const int Concurrency = 32;
|
||||
var resourcesResults = new IReadOnlyList<AgentSkillResource>?[Concurrency];
|
||||
var scriptsResults = new IReadOnlyList<AgentSkillScript>?[Concurrency];
|
||||
var contentResults = new string[Concurrency];
|
||||
|
||||
// Act — invoke all three accessors concurrently from many threads.
|
||||
await Task.WhenAll(Enumerable.Range(0, Concurrency).Select(i => Task.Run(async () =>
|
||||
{
|
||||
resourcesResults[i] = skill.Resources;
|
||||
scriptsResults[i] = skill.Scripts;
|
||||
contentResults[i] = await skill.GetContentAsync();
|
||||
})));
|
||||
|
||||
// Assert — every thread observed the same cached instances (no torn state).
|
||||
for (int i = 1; i < Concurrency; i++)
|
||||
{
|
||||
Assert.Same(resourcesResults[0], resourcesResults[i]);
|
||||
Assert.Same(scriptsResults[0], scriptsResults[i]);
|
||||
Assert.Same(contentResults[0], contentResults[i]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateScriptAndResource_WithSerializerOptions_HandleCustomTypesAsync()
|
||||
{
|
||||
@@ -151,17 +260,14 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange
|
||||
var skill = new AttributedScriptsSkill();
|
||||
|
||||
// Act
|
||||
var scripts = skill.Scripts;
|
||||
// Act & Assert — all scripts discovered with correct metadata
|
||||
Assert.NotNull(skill.Scripts);
|
||||
Assert.Equal(4, skill.Scripts!.Count);
|
||||
Assert.Contains(skill.Scripts, s => s.Name == "do-work");
|
||||
Assert.Contains(skill.Scripts, s => s.Name == "DefaultNamed");
|
||||
Assert.Contains(skill.Scripts, s => s.Name == "append");
|
||||
|
||||
// Assert — all scripts discovered with correct metadata
|
||||
Assert.NotNull(scripts);
|
||||
Assert.Equal(4, scripts!.Count);
|
||||
Assert.Contains(scripts, s => s.Name == "do-work");
|
||||
Assert.Contains(scripts, s => s.Name == "DefaultNamed");
|
||||
Assert.Contains(scripts, s => s.Name == "append");
|
||||
|
||||
var processScript = scripts.First(s => s.Name == "process");
|
||||
var processScript = skill.Scripts.First(s => s.Name == "process");
|
||||
Assert.Equal("Processes the input.", processScript.Description);
|
||||
}
|
||||
|
||||
@@ -272,16 +378,16 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttributedFullSkill_IncludesContentWithSchema_AndCachesMembers()
|
||||
public async Task AttributedFullSkill_IncludesContentWithSchema_AndCachesMembersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
// Act & Assert — Content includes reflected resources and scripts
|
||||
Assert.Contains("<resources>", skill.Content);
|
||||
Assert.Contains("conversion-table", skill.Content);
|
||||
Assert.Contains("<scripts>", skill.Content);
|
||||
Assert.Contains("convert", skill.Content);
|
||||
Assert.Contains("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("conversion-table", await skill.GetContentAsync());
|
||||
Assert.Contains("<scripts>", await skill.GetContentAsync());
|
||||
Assert.Contains("convert", await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — discovered members are cached
|
||||
Assert.Same(skill.Resources, skill.Resources);
|
||||
@@ -299,33 +405,33 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange — skill with no attributes and no overrides; base discovery returns null (not empty list)
|
||||
var skill = new NoAttributesNoOverridesSkill();
|
||||
var baseType = typeof(AgentClassSkill<NoAttributesNoOverridesSkill>);
|
||||
var resourcesDiscoveredField = baseType.GetField("_resourcesDiscovered", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var scriptsDiscoveredField = baseType.GetField("_scriptsDiscovered", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var reflectedResourcesField = baseType.GetField("_reflectedResources", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var reflectedScriptsField = baseType.GetField("_reflectedScripts", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var resourcesField = baseType.GetField("_resources", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var scriptsField = baseType.GetField("_scripts", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(resourcesDiscoveredField);
|
||||
Assert.NotNull(scriptsDiscoveredField);
|
||||
Assert.NotNull(reflectedResourcesField);
|
||||
Assert.NotNull(reflectedScriptsField);
|
||||
Assert.False((bool)resourcesDiscoveredField!.GetValue(skill)!);
|
||||
Assert.False((bool)scriptsDiscoveredField!.GetValue(skill)!);
|
||||
Assert.NotNull(resourcesField);
|
||||
Assert.NotNull(scriptsField);
|
||||
|
||||
var resourcesLazy = (Lazy<IReadOnlyList<AgentSkillResource>?>)resourcesField!.GetValue(skill)!;
|
||||
var scriptsLazy = (Lazy<IReadOnlyList<AgentSkillScript>?>)scriptsField!.GetValue(skill)!;
|
||||
|
||||
Assert.False(resourcesLazy.IsValueCreated);
|
||||
Assert.False(scriptsLazy.IsValueCreated);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.Scripts);
|
||||
Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!);
|
||||
Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!);
|
||||
Assert.Null(reflectedResourcesField!.GetValue(skill));
|
||||
Assert.Null(reflectedScriptsField!.GetValue(skill));
|
||||
Assert.True(resourcesLazy.IsValueCreated);
|
||||
Assert.True(scriptsLazy.IsValueCreated);
|
||||
Assert.Null(resourcesLazy.Value);
|
||||
Assert.Null(scriptsLazy.Value);
|
||||
|
||||
// Repeated access should not re-trigger discovery even when discovered value is null.
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.Scripts);
|
||||
Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!);
|
||||
Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!);
|
||||
Assert.Null(reflectedResourcesField.GetValue(skill));
|
||||
Assert.Null(reflectedScriptsField.GetValue(skill));
|
||||
Assert.True(resourcesLazy.IsValueCreated);
|
||||
Assert.True(scriptsLazy.IsValueCreated);
|
||||
Assert.Null(resourcesLazy.Value);
|
||||
Assert.Null(scriptsLazy.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -382,7 +488,7 @@ public sealed class AgentClassSkillTests
|
||||
var jso = SkillTestJsonContext.Default.Options;
|
||||
|
||||
// Act & Assert — script with custom JSO
|
||||
var script = skill.Scripts![0];
|
||||
var script = skill.Scripts!.First(s => s.Name == "lookup");
|
||||
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
|
||||
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
|
||||
var args = argsDoc.RootElement;
|
||||
@@ -398,13 +504,13 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IncludesDescription_ForReflectedResources()
|
||||
public async Task Content_IncludesDescription_ForReflectedResourcesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedResourcePropertiesSkill();
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — descriptions from [Description] attribute appear in synthesized content
|
||||
Assert.Contains("Some important data.", content);
|
||||
|
||||
@@ -105,7 +105,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithScripts_AppendsPerScriptEntries()
|
||||
public async Task Content_WithScripts_AppendsPerScriptEntriesAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
@@ -118,7 +118,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
scripts: [script1, script2]);
|
||||
|
||||
// Act
|
||||
var content = fileSkill.Content;
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert — content starts with original and appends per-script entries
|
||||
Assert.StartsWith("Original content", content);
|
||||
@@ -130,7 +130,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithoutScripts_ReturnsOriginalContent()
|
||||
public async Task Content_WithoutScripts_ReturnsOriginalContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fileSkill = new AgentFileSkill(
|
||||
@@ -139,14 +139,14 @@ public sealed class AgentFileSkillScriptTests
|
||||
"/skills/my-skill");
|
||||
|
||||
// Act
|
||||
var content = fileSkill.Content;
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Original content only", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithScripts_IsCached()
|
||||
public async Task Content_WithScripts_IsCachedAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
@@ -158,8 +158,8 @@ public sealed class AgentFileSkillScriptTests
|
||||
scripts: [script]);
|
||||
|
||||
// Act
|
||||
var content1 = fileSkill.Content;
|
||||
var content2 = fileSkill.Content;
|
||||
var content1 = await fileSkill.GetContentAsync();
|
||||
var content2 = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Same(content1, content2);
|
||||
@@ -232,7 +232,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_WithScripts_ContainsDefaultParametersSchema()
|
||||
public async Task Content_WithScripts_ContainsDefaultParametersSchemaAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
@@ -244,7 +244,7 @@ public sealed class AgentFileSkillScriptTests
|
||||
scripts: [script]);
|
||||
|
||||
// Act
|
||||
var content = fileSkill.Content;
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert — the appended block contains the actual default schema from AgentFileSkillScript
|
||||
Assert.Contains("""{"type":"array","items":{"type":"string"}}""", content);
|
||||
|
||||
+25
-25
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -46,9 +45,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.NotNull(skill.Scripts);
|
||||
Assert.Single(skill.Scripts!);
|
||||
Assert.Equal("scripts/convert.py", skill.Scripts![0].Name);
|
||||
var script = await skill.GetScriptAsync("scripts/convert.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("scripts/convert.py", script!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -69,14 +68,13 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(6, scriptNames.Count);
|
||||
Assert.Contains("scripts/run.cs", scriptNames);
|
||||
Assert.Contains("scripts/run.csx", scriptNames);
|
||||
Assert.Contains("scripts/run.js", scriptNames);
|
||||
Assert.Contains("scripts/run.ps1", scriptNames);
|
||||
Assert.Contains("scripts/run.py", scriptNames);
|
||||
Assert.Contains("scripts/run.sh", scriptNames);
|
||||
// Assert — verify all expected scripts are discoverable
|
||||
foreach (var name in (string[])["scripts/run.cs", "scripts/run.csx", "scripts/run.js", "scripts/run.ps1", "scripts/run.py", "scripts/run.sh"])
|
||||
{
|
||||
var script = await skills[0].GetScriptAsync(name);
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal(name, script!.Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -94,7 +92,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
Assert.Null(await skills[0].GetScriptAsync("scripts/data.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -109,8 +107,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.NotNull(skills[0].Scripts);
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
Assert.Null(await skills[0].GetScriptAsync("any-script"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -128,7 +125,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert — neither file is in the default scripts/ directory, so no scripts are discovered
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Scripts!);
|
||||
Assert.Null(await skills[0].GetScriptAsync("convert.py"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -150,7 +147,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], null, null, CancellationToken.None);
|
||||
var scriptResult = await (await skills[0].GetScriptAsync("scripts/test.py"))!.RunAsync(skills[0], null, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(executorCalled);
|
||||
@@ -175,7 +172,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Act — discovery succeeds even without a runner
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
var script = skills[0].Scripts![0];
|
||||
var script = (await skills[0].GetScriptAsync("scripts/run.sh"))!;
|
||||
|
||||
// Assert — running the script throws because no runner was provided
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], null, null, CancellationToken.None));
|
||||
@@ -195,8 +192,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name);
|
||||
var rbScript = await skills[0].GetScriptAsync("scripts/run.rb");
|
||||
Assert.NotNull(rbScript);
|
||||
Assert.Equal("scripts/run.rb", rbScript!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -217,7 +215,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
using var argumentsDoc = JsonDocument.Parse("""{"value":26.2,"factor":1.60934}""");
|
||||
var arguments = argumentsDoc.RootElement;
|
||||
await skills[0].Scripts![0].RunAsync(skills[0], arguments, null, CancellationToken.None);
|
||||
await (await skills[0].GetScriptAsync("scripts/test.py"))!.RunAsync(skills[0], arguments, null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedArgs);
|
||||
@@ -240,8 +238,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert — script file inside the deeply nested directory is discovered
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name);
|
||||
var nestedScript = await skills[0].GetScriptAsync("f1/f2/f3/run.py");
|
||||
Assert.NotNull(nestedScript);
|
||||
Assert.Equal("f1/f2/f3/run.py", nestedScript!.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -267,11 +266,12 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
|
||||
// Assert — scripts are discovered with names identical to using directories without "./"
|
||||
Assert.Single(skills);
|
||||
Assert.Equal(directories.Length, skills[0].Scripts!.Count);
|
||||
foreach (string directory in directories)
|
||||
{
|
||||
string expectedName = $"{directory.Substring(2)}/run.py";
|
||||
Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName);
|
||||
var script = await skills[0].GetScriptAsync(expectedName);
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal(expectedName, script!.Name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,13 +105,13 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ContainsNameDescriptionAndInstructions()
|
||||
public async Task Content_ContainsNameDescriptionAndInstructionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Do the thing.");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<name>my-skill</name>", content);
|
||||
@@ -120,13 +120,13 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_EscapesXmlCharacters()
|
||||
public async Task Content_EscapesXmlCharactersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "x<y>z\"w & it's more", "1 & 2 < 3");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<name>my-skill</name>", content);
|
||||
@@ -135,28 +135,28 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IsCachedAcrossAccesses()
|
||||
public async Task Content_IsCachedAcrossAccessesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var first = skill.Content;
|
||||
var second = skill.Content;
|
||||
var first = await skill.GetContentAsync();
|
||||
var second = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IncludesResourcesAddedBeforeFirstAccess()
|
||||
public async Task Content_IncludesResourcesAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("config", "value1", "A config resource.");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
@@ -164,14 +164,14 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IncludesDelegateResourcesAddedBeforeFirstAccess()
|
||||
public async Task Content_IncludesDelegateResourcesAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("dynamic", () => "hello");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
@@ -179,14 +179,14 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IncludesScriptsAddedBeforeFirstAccess()
|
||||
public async Task Content_IncludesScriptsAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("run", () => "result", "Runs something.");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<scripts>", content);
|
||||
@@ -194,22 +194,22 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IsCachedAndNotRebuilt()
|
||||
public async Task Content_IsCachedAndNotRebuiltAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("r1", "v1");
|
||||
|
||||
// Act
|
||||
var first = skill.Content;
|
||||
var second = skill.Content;
|
||||
var first = await skill.GetContentAsync();
|
||||
var second = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_IncludesResourcesAndScriptsAddedBeforeFirstAccess()
|
||||
public async Task Content_IncludesResourcesAndScriptsAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -217,7 +217,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddScript("s1", () => "ok");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
@@ -227,14 +227,14 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ParametersSchema_IsXmlEscaped()
|
||||
public async Task Content_ParametersSchema_IsXmlEscapedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("search", (string query, int limit) => $"found {limit} results for {query}");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — JSON schema should be present and XML content chars escaped
|
||||
Assert.Contains("parameters_schema", content);
|
||||
@@ -280,17 +280,103 @@ public sealed class AgentInlineSkillTests
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(skill.Resources);
|
||||
Assert.Null(skill.GetTestResources());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scripts_WhenNoneAdded_ReturnsNull()
|
||||
public async Task Scripts_WhenNoneAdded_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(skill.Scripts);
|
||||
Assert.Null(await skill.GetScriptAsync("nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("r1", "v1");
|
||||
skill.AddResource("r2", "v2");
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("r2");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(resource);
|
||||
Assert.Equal("r2", resource!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddResource("r1", "v1");
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResourceAsync_NoResourcesAdded_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var resource = await skill.GetResourceAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("s1", () => "first");
|
||||
skill.AddScript("s2", () => "second");
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("s2");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("s2", script!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("s1", () => "ok");
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScriptAsync_NoScriptsAdded_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var script = await skill.GetScriptAsync("missing");
|
||||
|
||||
// Assert
|
||||
Assert.Null(script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -333,13 +419,13 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTags()
|
||||
public async Task Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTagsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
@@ -347,58 +433,58 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ResourcesAddedAfterCaching_AreNotIncluded()
|
||||
public async Task Content_ResourcesAddedAfterCaching_AreNotIncludedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
_ = skill.Content; // trigger caching
|
||||
_ = await skill.GetContentAsync(); // trigger caching
|
||||
skill.AddResource("late-resource", "late-value");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — the late resource should not appear because content was cached
|
||||
Assert.DoesNotContain("late-resource", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ScriptsAddedAfterCaching_AreNotIncluded()
|
||||
public async Task Content_ScriptsAddedAfterCaching_AreNotIncludedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
_ = skill.Content; // trigger caching
|
||||
_ = await skill.GetContentAsync(); // trigger caching
|
||||
skill.AddScript("late-script", () => "late");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — the late script should not appear because content was cached
|
||||
Assert.DoesNotContain("late-script", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ScriptWithDescription_IncludesDescriptionAttribute()
|
||||
public async Task Content_ScriptWithDescription_IncludesDescriptionAttributeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("my-script", () => "ok", "Runs something.");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"Runs something.\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTag()
|
||||
public async Task Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTagAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
skill.AddScript("simple", () => "ok");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — parameterless Action delegates still produce a schema, so this
|
||||
// verifies the script is at least included in the output
|
||||
@@ -406,7 +492,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Content_ResourceWithDescription_IncludesDescriptionAttribute()
|
||||
public async Task Content_ResourceWithDescription_IncludesDescriptionAttributeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -414,7 +500,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddResource("no-desc", "value");
|
||||
|
||||
// Act
|
||||
var content = skill.Content;
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"A described resource.\"", content);
|
||||
@@ -437,7 +523,7 @@ public sealed class AgentInlineSkillTests
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await (await skill.GetScriptAsync("lookup"))!.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert — the custom input was deserialized via skill-level JSO and response was produced
|
||||
Assert.NotNull(result);
|
||||
@@ -461,7 +547,7 @@ public sealed class AgentInlineSkillTests
|
||||
var args = argsDoc.RootElement;
|
||||
|
||||
// Act
|
||||
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
|
||||
var result = await (await skill.GetScriptAsync("lookup"))!.RunAsync(skill, args, null, CancellationToken.None);
|
||||
|
||||
// Assert — per-script JSO takes effect and custom types are properly marshaled
|
||||
Assert.NotNull(result);
|
||||
@@ -477,7 +563,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true });
|
||||
|
||||
// Act
|
||||
var result = await skill.Resources![0].ReadAsync();
|
||||
var result = await skill.GetTestResources()![0].ReadAsync();
|
||||
|
||||
// Assert — the custom type was returned successfully via skill-level JSO
|
||||
Assert.NotNull(result);
|
||||
@@ -494,7 +580,7 @@ public sealed class AgentInlineSkillTests
|
||||
skill.AddResource("config", () => new SkillConfig { Theme = "dark", Verbose = true }, serializerOptions: resourceJso);
|
||||
|
||||
// Act
|
||||
var result = await skill.Resources![0].ReadAsync();
|
||||
var result = await skill.GetTestResources()![0].ReadAsync();
|
||||
|
||||
// Assert — per-resource JSO takes effect and custom type is properly marshaled
|
||||
Assert.NotNull(result);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only helpers that peek at the underlying resource list of a skill via reflection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The public <see cref="AgentSkill"/> API exposes resources only through
|
||||
/// <see cref="AgentSkill.GetResourceAsync"/>.
|
||||
/// These helpers exist purely to allow unit tests for <see cref="AgentFileSkill"/> and
|
||||
/// <see cref="AgentInlineSkill"/> to inspect the concrete enumerated list a skill carries.
|
||||
/// </remarks>
|
||||
internal static class AgentSkillTestExtensions
|
||||
{
|
||||
public static IReadOnlyList<AgentSkillResource>? GetTestResources(this AgentSkill skill)
|
||||
{
|
||||
// AgentFileSkill / AgentInlineSkill: private "_resources" field.
|
||||
for (var type = skill.GetType(); type is not null; type = type.BaseType)
|
||||
{
|
||||
var field = type.GetField("_resources", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field is not null)
|
||||
{
|
||||
return UnwrapList(field.GetValue(skill));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AgentSkillResource>? UnwrapList(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => null,
|
||||
IReadOnlyList<AgentSkillResource> list => list,
|
||||
IEnumerable<AgentSkillResource> seq => seq.ToList(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
+282
-10
@@ -68,11 +68,12 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
Assert.Contains("provider-skill", result.Instructions);
|
||||
Assert.Contains("Provider skill test", result.Instructions);
|
||||
|
||||
// Should have load_skill tool (no resources, so no read_skill_resource)
|
||||
// Should have load_skill, read_skill_resource, and run_skill_script tools
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
Assert.DoesNotContain("read_skill_resource", toolNames);
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
Assert.Contains("run_skill_script", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -316,7 +317,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync()
|
||||
public async Task InvokingCoreAsync_WithoutScripts_StillIncludesAllToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("no-script-skill", "No scripts", "Body.");
|
||||
@@ -328,10 +329,12 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
// Assert — all tools are always included regardless of skill content
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.DoesNotContain("run_skill_script", toolNames);
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
Assert.Contains("run_skill_script", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -416,7 +419,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var fileSkill = Assert.IsType<AgentFileSkill>(skills[0]);
|
||||
Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name));
|
||||
Assert.All(fileSkill.GetTestResources()!, r => Assert.EndsWith(".json", r.Name));
|
||||
}
|
||||
|
||||
private void CreateSkill(string name, string description, string body)
|
||||
@@ -445,6 +448,279 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
Assert.Contains("Skill body.", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_EmptySkillName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("any-skill", "Test", "Body.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "" }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_SkillNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.CreateSkill("only-skill", "Test", "Body.");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "non-existent" }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_WithResources_IncludesReadSkillResourceToolAsync()
|
||||
{
|
||||
// Arrange — inline skill with a resource
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "value1", "A config resource.");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_ReturnsResourceContentAsync()
|
||||
{
|
||||
// Arrange — inline skill with a resource
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "resource-value");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "res-skill",
|
||||
["resourceName"] = "config",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("resource-value", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_EmptySkillName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "",
|
||||
["resourceName"] = "config",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_EmptyResourceName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "res-skill",
|
||||
["resourceName"] = "",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Resource name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_SkillNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "non-existent",
|
||||
["resourceName"] = "config",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSkillResource_ResourceNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
||||
skill.AddResource("config", "v");
|
||||
var provider = new AgentSkillsProvider(skill);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "res-skill",
|
||||
["resourceName"] = "missing",
|
||||
})
|
||||
{
|
||||
Services = new TestServiceProvider(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Resource 'missing' not found in skill 'res-skill'.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_EmptySkillName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "",
|
||||
["scriptName"] = "scripts/run.py",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_EmptyScriptName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script2-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script2-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "err-script2-skill",
|
||||
["scriptName"] = "",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Script name cannot be empty.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_SkillNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script3-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script3-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "non-existent",
|
||||
["scriptName"] = "scripts/run.py",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunSkillScript_ScriptNotFound_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "err-script4-skill");
|
||||
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script4-skill\ndescription: Test\n---\nBody.");
|
||||
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
||||
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
||||
{
|
||||
["skillName"] = "err-script4-skill",
|
||||
["scriptName"] = "scripts/missing.py",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Error: Script 'scripts/missing.py' not found in skill 'err-script4-skill'.", content!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync()
|
||||
{
|
||||
@@ -998,9 +1274,5 @@ public sealed class AgentSkillsProviderTests : IDisposable
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
protected override string Instructions => this._instructions;
|
||||
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
}
|
||||
}
|
||||
|
||||
+44
-42
@@ -281,9 +281,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.Resources!.Count);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(2, skill.GetTestResources()!.Count);
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("references/FAQ.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("assets/data.json", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -306,8 +306,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/data.json", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/data.json", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -329,8 +329,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/notes.md", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/notes.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -355,9 +355,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only the file directly in references/ is discovered; the nested file is not
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(skill.Resources!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("references/top.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(skill.GetTestResources()!, r => r.Name.Contains("deep.md", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -380,8 +380,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only .custom files should be discovered, not .json
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/data.custom", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/data.custom", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -406,7 +406,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — default extensions include .md
|
||||
var skills = await source.GetSkillsAsync();
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -442,7 +442,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -465,9 +465,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.Resources!.Count);
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(2, skill.GetTestResources()!.Count);
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(skill.GetTestResources()!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -488,7 +488,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — non-spec directories are not scanned by default
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -514,8 +514,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only docs/ is scanned; references/ is NOT scanned
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("docs/readme.md", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("docs/readme.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -530,7 +530,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].Resources!);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -588,7 +588,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
var skills = await source.GetSkillsAsync();
|
||||
var resource = skills[0].Resources!.First(r => r.Name == "references/doc.md");
|
||||
var resource = skills[0].GetTestResources()!.First(r => r.Name == "references/doc.md");
|
||||
|
||||
// Act
|
||||
var content = await resource.ReadAsync();
|
||||
@@ -672,8 +672,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — skill should still load, the symlinked references/ is skipped, assets/legit.md is found
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("assets/legit.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -714,8 +714,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only assets/legit.md is found; the symlinked references/ directory is skipped entirely
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-directory-skip");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("assets/legit.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -751,7 +751,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — skill loads but scripts from the symlinked directory are not discovered
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Empty(skill.Scripts!);
|
||||
Assert.Null(await skill.GetScriptAsync("any-script"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -791,7 +791,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — the symlinked intermediate segment causes the directory to be skipped
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Empty(skill.Resources!);
|
||||
Assert.Empty(skill.GetTestResources()!);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1020,8 +1020,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — only one copy of the resource despite two equivalent directory entries
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("references/FAQ.md", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1043,8 +1043,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — trailing slash variant deduplicated
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal("references/data.json", skills[0].Resources![0].Name);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1066,8 +1066,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — backslash variant deduplicated
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Scripts!);
|
||||
Assert.Equal("scripts/run.py", skills[0].Scripts![0].Name);
|
||||
var script = await skills[0].GetScriptAsync("scripts/run.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("scripts/run.py", script!.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -1093,8 +1094,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
|
||||
// Assert — the resource is discovered with a name identical to using the directory without "./"
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].Resources!);
|
||||
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1117,8 +1118,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — resource file inside the deeply nested directory is discovered
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("f1/f2/f3/data.json", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("f1/f2/f3/data.json", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
private string CreateSkillDirectory(string name, string description, string body)
|
||||
@@ -1188,8 +1189,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — script at the skill root should be discovered
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Scripts!);
|
||||
Assert.Equal("run.py", skill.Scripts![0].Name);
|
||||
var script = await skill.GetScriptAsync("run.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("run.py", script!.Name);
|
||||
}
|
||||
|
||||
#if NET
|
||||
@@ -1229,8 +1231,8 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Assert — only legit.md should be discovered; the symlinked leak.md is skipped
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-file-skill");
|
||||
Assert.NotNull(skill);
|
||||
Assert.Single(skill.Resources!);
|
||||
Assert.Equal("references/legit.md", skill.Resources![0].Name);
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("references/legit.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests that verify the Hosted-AgentSkills sample patterns: ZIP extraction with
|
||||
/// zip-slip guard, skill name validation, and AgentSkillsProvider loading from
|
||||
/// downloaded skill directories (the Foundry download → extract → wire-into-provider flow).
|
||||
/// </summary>
|
||||
public sealed class HostedAgentSkillsPatternTests : IDisposable
|
||||
{
|
||||
private readonly string _testRoot;
|
||||
private readonly TestAIAgent _agent = new();
|
||||
|
||||
public HostedAgentSkillsPatternTests()
|
||||
{
|
||||
this._testRoot = Path.Combine(Path.GetTempPath(), "hosted-skills-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(this._testRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(this._testRoot))
|
||||
{
|
||||
Directory.Delete(this._testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ZIP extraction tests ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_ValidArchive_ExtractsToDestination()
|
||||
{
|
||||
// Arrange
|
||||
string destDir = Path.Combine(this._testRoot, "valid-extract");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithEntry("SKILL.md", "---\nname: test\ndescription: Test\n---\nBody.");
|
||||
|
||||
// Act
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
SafeExtractZip(archive, destDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "SKILL.md")));
|
||||
string content = File.ReadAllText(Path.Combine(destDir, "SKILL.md"));
|
||||
Assert.Contains("name: test", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_ZipSlipAttempt_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
string destDir = Path.Combine(this._testRoot, "zipslip-test");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithEntry("../../../evil.txt", "malicious content");
|
||||
|
||||
// Act & Assert
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
|
||||
Assert.Contains("outside of", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_SiblingPrefixAttack_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange — sibling path that starts with the dest dir name
|
||||
string destDir = Path.Combine(this._testRoot, "target");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithEntry("../target-evil/payload.txt", "exploit");
|
||||
|
||||
// Act & Assert
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
|
||||
Assert.Contains("outside of", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeExtractZip_DirectoryEntry_CreatesDirectory()
|
||||
{
|
||||
// Arrange
|
||||
string destDir = Path.Combine(this._testRoot, "dir-entry");
|
||||
Directory.CreateDirectory(destDir);
|
||||
byte[] zip = CreateZipWithDirectoryEntry("subdir/");
|
||||
|
||||
// Act
|
||||
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
|
||||
SafeExtractZip(archive, destDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(Directory.Exists(Path.Combine(destDir, "subdir")));
|
||||
}
|
||||
|
||||
// ── Skill name validation tests ──────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("../escape")]
|
||||
[InlineData("path/traversal")]
|
||||
[InlineData("path\\traversal")]
|
||||
[InlineData("has.dots")]
|
||||
public void ValidateSkillName_InvalidNames_Rejected(string name)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.True(IsInvalidSkillName(name), $"Expected '{name}' to be rejected.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("support-style")]
|
||||
[InlineData("escalation-policy")]
|
||||
[InlineData("my-skill-123")]
|
||||
public void ValidateSkillName_ValidNames_Accepted(string name)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.False(IsInvalidSkillName(name), $"Expected '{name}' to be accepted.");
|
||||
}
|
||||
|
||||
// ── AgentSkillsProvider integration with downloaded skill directories ─────
|
||||
|
||||
[Fact]
|
||||
public async Task AgentSkillsProvider_WithDownloadedSkills_AdvertisesAndLoadsAsync()
|
||||
{
|
||||
// Arrange — simulate the Foundry download + extract flow
|
||||
string downloadDir = Path.Combine(this._testRoot, "downloaded_skills");
|
||||
Directory.CreateDirectory(downloadDir);
|
||||
|
||||
CreateDownloadedSkill(downloadDir, "support-style",
|
||||
"---\nname: support-style\ndescription: Contoso Outdoors customer-support tone and formatting guidelines.\n---\n\n# Contoso Outdoors Support Style\n\nYou are speaking on behalf of Contoso Outdoors.\n\n## Canary\n\nInclude STYLE-CANARY-3318.");
|
||||
CreateDownloadedSkill(downloadDir, "escalation-policy",
|
||||
"---\nname: escalation-policy\ndescription: When and how to escalate Contoso Outdoors customer-support tickets.\n---\n\n# Escalation Policy\n\nProvide ESC-CANARY-7742.");
|
||||
|
||||
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
|
||||
var inputContext = new AIContext
|
||||
{
|
||||
Instructions = "You are a customer-support assistant for Contoso Outdoors."
|
||||
};
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — skills are advertised in instructions
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("support-style", result.Instructions);
|
||||
Assert.Contains("escalation-policy", result.Instructions);
|
||||
Assert.Contains("Contoso Outdoors customer-support tone", result.Instructions);
|
||||
|
||||
// Assert — load_skill tool is available
|
||||
Assert.NotNull(result.Tools);
|
||||
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
||||
Assert.Contains("load_skill", toolNames);
|
||||
// All tools are always included regardless of whether skills have resources or scripts
|
||||
Assert.Contains("read_skill_resource", toolNames);
|
||||
Assert.Contains("run_skill_script", toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_ReturnsFullContentWithCanaryAsync()
|
||||
{
|
||||
// Arrange
|
||||
string downloadDir = Path.Combine(this._testRoot, "canary_skills");
|
||||
Directory.CreateDirectory(downloadDir);
|
||||
CreateDownloadedSkill(downloadDir, "support-style",
|
||||
"---\nname: support-style\ndescription: Contoso tone guidelines.\n---\n\nInclude STYLE-CANARY-3318 at the bottom.");
|
||||
|
||||
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
Assert.NotNull(loadSkillTool);
|
||||
|
||||
// Act
|
||||
var content = await loadSkillTool!.InvokeAsync(
|
||||
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "support-style" }));
|
||||
|
||||
// Assert
|
||||
var text = content!.ToString()!;
|
||||
Assert.Contains("STYLE-CANARY-3318", text);
|
||||
Assert.Contains("name: support-style", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSkill_UnknownName_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
string downloadDir = Path.Combine(this._testRoot, "error_skills");
|
||||
Directory.CreateDirectory(downloadDir);
|
||||
CreateDownloadedSkill(downloadDir, "support-style",
|
||||
"---\nname: support-style\ndescription: Test\n---\nBody.");
|
||||
|
||||
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
|
||||
var inputContext = new AIContext();
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
||||
|
||||
// Act
|
||||
var content = await loadSkillTool!.InvokeAsync(
|
||||
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "nonexistent-skill" }));
|
||||
|
||||
// Assert
|
||||
var text = content!.ToString()!;
|
||||
Assert.Contains("Error", text);
|
||||
Assert.Contains("not found", text);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a downloaded skill directory with a SKILL.md file — simulating what
|
||||
/// the Foundry download + ZIP extract flow produces.
|
||||
/// </summary>
|
||||
private static void CreateDownloadedSkill(string parentDir, string name, string content)
|
||||
{
|
||||
string skillDir = Path.Combine(parentDir, name);
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ZIP archive in memory containing a single file entry.
|
||||
/// </summary>
|
||||
private static byte[] CreateZipWithEntry(string entryName, string content)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var entry = archive.CreateEntry(entryName);
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
writer.Write(content);
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ZIP archive in memory containing a single directory entry.
|
||||
/// </summary>
|
||||
private static byte[] CreateZipWithDirectoryEntry(string directoryName)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
// Directory entries in ZIPs have an empty name portion and end with /
|
||||
archive.CreateEntry(directoryName);
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the zip-slip guard from the Hosted-AgentSkills sample Program.cs.
|
||||
/// </summary>
|
||||
private static void SafeExtractZip(ZipArchive archive, string destinationDir)
|
||||
{
|
||||
string destRoot = Path.GetFullPath(destinationDir);
|
||||
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
|
||||
? destRoot
|
||||
: destRoot + Path.DirectorySeparatorChar;
|
||||
|
||||
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.CreateDirectory(entryPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
|
||||
entry.ExtractToFile(entryPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the skill name validation from the Hosted-AgentSkills sample Program.cs.
|
||||
/// </summary>
|
||||
private static bool IsInvalidSkillName(string name) =>
|
||||
name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name);
|
||||
}
|
||||
@@ -31,13 +31,7 @@ internal sealed class TestAgentSkill : AgentSkill
|
||||
public override AgentSkillFrontmatter Frontmatter => this._frontmatter;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content => this._content;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default) => new(this._content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+11
-11
@@ -73,7 +73,7 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
|
||||
// Act
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
@@ -90,7 +90,7 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
|
||||
// Act
|
||||
object? result = await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
@@ -107,8 +107,8 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, provider, session) = await CreateToolsWithProviderAndSessionAsync();
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
AIFunction getMode = GetTool(tools, "AgentMode_Get");
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
AIFunction getMode = GetTool(tools, "mode_get");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
@@ -131,7 +131,7 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction getMode = GetTool(tools, "AgentMode_Get");
|
||||
AIFunction getMode = GetTool(tools, "mode_get");
|
||||
|
||||
// Act
|
||||
object? result = await getMode.InvokeAsync(new AIFunctionArguments());
|
||||
@@ -148,8 +148,8 @@ public class AgentModeProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction setMode = GetTool(tools, "AgentMode_Set");
|
||||
AIFunction getMode = GetTool(tools, "AgentMode_Get");
|
||||
AIFunction setMode = GetTool(tools, "mode_set");
|
||||
AIFunction getMode = GetTool(tools, "mode_get");
|
||||
|
||||
// Act
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
@@ -236,7 +236,7 @@ public class AgentModeProviderTests
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction getMode = GetTool(result.Tools!, "AgentMode_Get");
|
||||
AIFunction getMode = GetTool(result.Tools!, "mode_get");
|
||||
object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments());
|
||||
|
||||
// Assert
|
||||
@@ -264,12 +264,12 @@ public class AgentModeProviderTests
|
||||
|
||||
// Act — first invocation changes mode
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction setMode = GetTool(result1.Tools!, "AgentMode_Set");
|
||||
AIFunction setMode = GetTool(result1.Tools!, "mode_set");
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
|
||||
// Second invocation should see the updated mode
|
||||
AIContext result2 = await provider.InvokingAsync(context);
|
||||
AIFunction getMode = GetTool(result2.Tools!, "AgentMode_Get");
|
||||
AIFunction getMode = GetTool(result2.Tools!, "mode_get");
|
||||
object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments());
|
||||
|
||||
// Assert
|
||||
@@ -579,7 +579,7 @@ public class AgentModeProviderTests
|
||||
|
||||
// First call to initialize
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction setMode = GetTool(result1.Tools!, "AgentMode_Set");
|
||||
AIFunction setMode = GetTool(result1.Tools!, "mode_set");
|
||||
|
||||
// Change mode via the tool (agent-initiated)
|
||||
await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" });
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<Compile Remove="EvaluationTests.cs" />
|
||||
<Compile Remove="AgentSkills\HostedAgentSkillsPatternTests.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import asyncio.coroutines
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import typing
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
@@ -75,6 +79,51 @@ class ExperimentalWarning(FeatureStageWarning):
|
||||
"""Warning emitted when an experimental API is used."""
|
||||
|
||||
|
||||
# Sentinel attribute used to detect (and reuse) a formatter we've already
|
||||
# installed. This lets the install be idempotent across re-imports / reloads
|
||||
# and keeps a stable reference to the previous formatter for testing or
|
||||
# external restoration via ``warnings.formatwarning = original``.
|
||||
_FEATURE_STAGE_FORMATTER_MARKER = "__feature_stage_formatter__"
|
||||
|
||||
|
||||
def _install_feature_stage_formatter() -> None:
|
||||
"""Install a single-line formatter for FeatureStageWarning categories.
|
||||
|
||||
The stdlib default formatter emits two lines (header + source snippet)
|
||||
which is noisy for our warnings — the offending class/function name is
|
||||
already in the message, so a one-line ``file:lineno: Category: message``
|
||||
is enough. Other warning categories are delegated to the previous
|
||||
formatter so we never change behaviour for unrelated warnings.
|
||||
|
||||
The install is idempotent: if a formatter installed by this module is
|
||||
already in place, we leave it alone so re-imports (and any third-party
|
||||
formatter wrapped on top of ours) don't get wrapped multiple times.
|
||||
"""
|
||||
current = warnings.formatwarning
|
||||
if getattr(current, _FEATURE_STAGE_FORMATTER_MARKER, False):
|
||||
return
|
||||
|
||||
def _formatwarning(
|
||||
message: Warning | str,
|
||||
category: type[Warning],
|
||||
filename: str,
|
||||
lineno: int,
|
||||
line: str | None = None,
|
||||
) -> str:
|
||||
if issubclass(category, FeatureStageWarning):
|
||||
return f"{filename}:{lineno}: {category.__name__}: {message}\n"
|
||||
return current(message, category, filename, lineno, line)
|
||||
|
||||
setattr(_formatwarning, _FEATURE_STAGE_FORMATTER_MARKER, True)
|
||||
# Keep a reference to the wrapped formatter so callers (tests, embedders)
|
||||
# can restore the previous behaviour if they need to.
|
||||
_formatwarning.__wrapped__ = current # type: ignore[attr-defined]
|
||||
warnings.formatwarning = _formatwarning
|
||||
|
||||
|
||||
_install_feature_stage_formatter()
|
||||
|
||||
|
||||
def _normalize_feature_id(feature_id: str | Enum) -> str:
|
||||
return str(feature_id.value if isinstance(feature_id, Enum) else feature_id)
|
||||
|
||||
@@ -109,23 +158,91 @@ def _set_feature_stage_metadata(obj: Any, *, stage: FeatureStageName, feature_id
|
||||
setattr(obj, _FEATURE_ID_ATTR, feature_id)
|
||||
|
||||
|
||||
_INTERNAL_FRAME_FILE = os.path.normcase(__file__)
|
||||
# Module names whose frames we never want to surface as the caller. ``abc`` is
|
||||
# the big one (its ``__new__`` shows up as ``<frozen abc>:106`` for ABC-driven
|
||||
# subclass creation on modern CPython, so we cannot rely on filename matching).
|
||||
# ``functools``/``typing``/``contextlib`` are added because they often wrap our
|
||||
# decorators or appear in the metaclass call path.
|
||||
_INTERNAL_FRAME_MODULES: frozenset[str] = frozenset({
|
||||
abc.__name__,
|
||||
functools.__name__,
|
||||
typing.__name__,
|
||||
contextlib.__name__,
|
||||
})
|
||||
|
||||
|
||||
def _is_internal_frame(frame: Any) -> bool:
|
||||
if os.path.normcase(frame.f_code.co_filename) == _INTERNAL_FRAME_FILE:
|
||||
return True
|
||||
module_name = frame.f_globals.get("__name__", "")
|
||||
if module_name in _INTERNAL_FRAME_MODULES:
|
||||
return True
|
||||
# Submodules of the skipped stdlib packages (``typing.ext``, ``functools``
|
||||
# wrappers under ``concurrent.futures._base``, etc.) are also wrappers we
|
||||
# don't want to surface.
|
||||
return any(module_name.startswith(prefix + ".") for prefix in _INTERNAL_FRAME_MODULES)
|
||||
|
||||
|
||||
def _resolve_user_frame() -> tuple[str, int, str] | None:
|
||||
"""Resolve the user frame that triggered an experimental warning.
|
||||
|
||||
Walk the stack and return ``(filename, lineno, module_name)`` for the first
|
||||
frame outside this module and the wrapping/metaclass machinery.
|
||||
|
||||
Returns ``None`` if no such frame is found; callers fall back to plain
|
||||
``warnings.warn`` with a fixed stacklevel.
|
||||
"""
|
||||
# Frame objects participate in reference cycles (``frame -> f_locals ->
|
||||
# frame``) and can delay GC if held implicitly. Capture the user frame's
|
||||
# data into plain values inside the try, and explicitly delete the frame
|
||||
# references in finally so we never leak frames across this call. This
|
||||
# follows CPython's own guidance for code that uses ``inspect.currentframe``.
|
||||
frame = inspect.currentframe()
|
||||
candidate: Any = None
|
||||
try:
|
||||
if frame is None:
|
||||
return None
|
||||
# Skip _resolve_user_frame itself + the warn helper that called it.
|
||||
candidate = frame.f_back.f_back if frame.f_back and frame.f_back.f_back else None
|
||||
while candidate is not None:
|
||||
if not _is_internal_frame(candidate):
|
||||
return (
|
||||
candidate.f_code.co_filename,
|
||||
candidate.f_lineno,
|
||||
candidate.f_globals.get("__name__", "<unknown>"),
|
||||
)
|
||||
candidate = candidate.f_back
|
||||
return None
|
||||
finally:
|
||||
del frame, candidate
|
||||
|
||||
|
||||
def _warn_on_feature_use(
|
||||
*,
|
||||
stage: FeatureStageName,
|
||||
feature_id: str,
|
||||
object_name: str,
|
||||
category: type[Warning],
|
||||
stacklevel: int,
|
||||
) -> None:
|
||||
warning_key = (category, feature_id)
|
||||
if warning_key in _WARNED_FEATURES:
|
||||
return
|
||||
|
||||
warnings.warn(
|
||||
_build_stage_warning_message(stage=stage, feature_id=feature_id, object_name=object_name),
|
||||
category=category,
|
||||
stacklevel=stacklevel,
|
||||
)
|
||||
message = _build_stage_warning_message(stage=stage, feature_id=feature_id, object_name=object_name)
|
||||
user_frame = _resolve_user_frame()
|
||||
if user_frame is None:
|
||||
# Last-resort fallback: emit at the immediate caller of this helper.
|
||||
warnings.warn(message, category=category, stacklevel=2)
|
||||
else:
|
||||
filename, lineno, module = user_frame
|
||||
warnings.warn_explicit(
|
||||
message,
|
||||
category=category,
|
||||
filename=filename,
|
||||
lineno=lineno,
|
||||
module=module,
|
||||
)
|
||||
_WARNED_FEATURES.add(warning_key)
|
||||
|
||||
|
||||
@@ -150,7 +267,6 @@ def _add_runtime_warning(
|
||||
feature_id=feature_id,
|
||||
object_name=object_name,
|
||||
category=category,
|
||||
stacklevel=3,
|
||||
)
|
||||
if original_new is not object.__new__:
|
||||
return original_new(cls, *args, **kwargs)
|
||||
@@ -171,7 +287,6 @@ def _add_runtime_warning(
|
||||
feature_id=feature_id,
|
||||
object_name=object_name,
|
||||
category=category,
|
||||
stacklevel=3,
|
||||
)
|
||||
return original_init_subclass_func(*args, **kwargs)
|
||||
|
||||
@@ -185,7 +300,6 @@ def _add_runtime_warning(
|
||||
feature_id=feature_id,
|
||||
object_name=object_name,
|
||||
category=category,
|
||||
stacklevel=3,
|
||||
)
|
||||
return original_init_subclass(*args, **kwargs)
|
||||
|
||||
@@ -200,7 +314,6 @@ def _add_runtime_warning(
|
||||
feature_id=feature_id,
|
||||
object_name=object_name,
|
||||
category=category,
|
||||
stacklevel=3,
|
||||
)
|
||||
return obj(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -14,14 +14,19 @@ from .._types import Message
|
||||
DEFAULT_MODE_SOURCE_ID = "agent_mode"
|
||||
DEFAULT_MODE_INSTRUCTIONS = (
|
||||
"## Agent Mode\n\n"
|
||||
"You can operate in different modes. Depending on the mode you are in, "
|
||||
"you will be required to follow different processes.\n\n"
|
||||
"Use the get_mode tool to check your current operating mode.\n"
|
||||
"Use the set_mode tool to switch between modes as your work progresses. "
|
||||
"Only use set_mode if the user explicitly instructs/allows you to change modes.\n\n"
|
||||
"- You can operate in different modes. Depending on the mode you are in, "
|
||||
"you will be required to follow different processes.\n"
|
||||
"- 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.\n\n"
|
||||
"Use the mode_get tool to check your current operating mode.\n"
|
||||
"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.\n\n"
|
||||
"You are currently operating in the {current_mode} mode.\n\n"
|
||||
"### Mandatory Mode based Workflow\n\n"
|
||||
"For every new substantive user request, including short factual questions, "
|
||||
"your behavior is determined by the mode you are in.\n\n"
|
||||
"{available_modes}\n"
|
||||
"\n"
|
||||
"You are currently operating in the {current_mode} mode.\n"
|
||||
)
|
||||
DEFAULT_MODE_CHANGE_NOTIFICATION = (
|
||||
'[Mode changed: The operating mode has been switched from "{previous_mode}" to "{current_mode}". '
|
||||
@@ -31,13 +36,37 @@ DEFAULT_MODE_DESCRIPTIONS: dict[str, str] = {
|
||||
"plan": (
|
||||
"Use this mode when analyzing requirements, breaking down tasks, and creating plans. "
|
||||
"This is the interactive mode — ask clarifying questions, discuss options, and get user approval before "
|
||||
"proceeding."
|
||||
"proceeding.\n\n"
|
||||
"Process to follow when in plan mode:\n"
|
||||
"1. Analyze the request with the purpose of building a research plan.\n"
|
||||
"2. Create a list of todo items.\n"
|
||||
"3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine "
|
||||
"what clarifying questions you may need from the user.\n"
|
||||
"4. Ask for clarifications from the user where needed.\n"
|
||||
" 1. Ask each clarification one by one.\n"
|
||||
" 2. When asking for clarification and you have specific options in mind, present them to the user, "
|
||||
"so they can choose the option instead of having to retype the entire response.\n"
|
||||
" 3. Do not proceed until you have received all the needed clarifications.\n"
|
||||
" 4. Do short exploratory research if it helps with being able to ask sensible clarifications from "
|
||||
"the user.\n"
|
||||
"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.\n"
|
||||
"6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.\n"
|
||||
"7. When approval is granted, always switch to execute mode (using the `mode_set` tool), "
|
||||
"and follow the steps for *Execute mode*."
|
||||
),
|
||||
"execute": (
|
||||
"Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask "
|
||||
"the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, "
|
||||
"useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note "
|
||||
"your choice."
|
||||
"Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask "
|
||||
"the user questions or wait for feedback.\n\n"
|
||||
"Process to follow when in execute mode:\n"
|
||||
"1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. "
|
||||
"(**Skip this step if you came from plan mode**)\n"
|
||||
"2. Work autonomously — use your best judgment to make decisions and keep progressing without asking "
|
||||
"the user questions. The goal is to have a complete, useful result ready when the user returns.\n"
|
||||
"3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable "
|
||||
"option, note your choice, and keep going.\n"
|
||||
"4. Mark tasks as completed as you finish them.\n"
|
||||
"5. Continue working, thinking and calling tools until you have the research result for the user."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -179,8 +208,8 @@ class AgentModeProvider(ContextProvider):
|
||||
``"plan"`` (interactive planning) and ``"execute"`` (autonomous execution).
|
||||
|
||||
This provider exposes the following tools to the agent:
|
||||
- ``set_mode``: Switch the agent's operating mode.
|
||||
- ``get_mode``: Retrieve the agent's current operating mode.
|
||||
- ``mode_set``: Switch the agent's operating mode.
|
||||
- ``mode_get``: Retrieve the agent's current operating mode.
|
||||
|
||||
Public helper functions ``get_agent_mode`` and ``set_agent_mode`` allow external code to programmatically read
|
||||
and change the mode.
|
||||
@@ -223,7 +252,7 @@ class AgentModeProvider(ContextProvider):
|
||||
def _build_instructions(self, current_mode: str) -> str:
|
||||
"""Build the mode guidance injected for the current session."""
|
||||
mode_lines = "".join(
|
||||
f'- "{self._mode_display_names[mode]}": {description}\n'
|
||||
f"#### {self._mode_display_names[mode]}\n\n{description}\n\n"
|
||||
for mode, description in self.mode_descriptions.items()
|
||||
)
|
||||
instructions = self.instructions or DEFAULT_MODE_INSTRUCTIONS
|
||||
@@ -257,8 +286,8 @@ class AgentModeProvider(ContextProvider):
|
||||
provider_state = _get_mode_state(session, source_id=self.source_id)
|
||||
previous_mode = provider_state.pop(_PREVIOUS_MODE_STATE_KEY, None)
|
||||
|
||||
@tool(name="set_mode", approval_mode="never_require")
|
||||
def set_mode(mode: str) -> str:
|
||||
@tool(name="mode_set", approval_mode="never_require")
|
||||
def mode_set(mode: str) -> str:
|
||||
"""Switch the agent's operating mode."""
|
||||
# The agent invoked the tool itself, so it knows the mode just changed — bypass
|
||||
# ``set_agent_mode`` to avoid triggering a notification message on the next turn.
|
||||
@@ -267,8 +296,8 @@ class AgentModeProvider(ContextProvider):
|
||||
tool_state["current_mode"] = normalized_mode
|
||||
return json.dumps({"mode": normalized_mode, "message": f"Mode changed to '{normalized_mode}'."})
|
||||
|
||||
@tool(name="get_mode", approval_mode="never_require")
|
||||
def get_mode() -> str:
|
||||
@tool(name="mode_get", approval_mode="never_require")
|
||||
def mode_get() -> str:
|
||||
"""Get the agent's current operating mode."""
|
||||
current_mode_value = get_agent_mode(
|
||||
session,
|
||||
@@ -282,11 +311,11 @@ class AgentModeProvider(ContextProvider):
|
||||
self.source_id,
|
||||
[self._build_instructions(current_mode)],
|
||||
)
|
||||
context.extend_tools(self.source_id, [set_mode, get_mode])
|
||||
context.extend_tools(self.source_id, [mode_set, mode_get])
|
||||
if isinstance(previous_mode, str) and previous_mode != current_mode:
|
||||
# Inject a user-role message announcing the external mode change. System instructions
|
||||
# always render first in the chat history, so the agent can otherwise stay anchored to
|
||||
# the most recent ``set_mode`` tool call rather than the new mode.
|
||||
# the most recent ``mode_set`` tool call rather than the new mode.
|
||||
previous_display = self._mode_display_names.get(previous_mode, previous_mode)
|
||||
current_display = self._mode_display_names.get(current_mode, current_mode)
|
||||
notification = DEFAULT_MODE_CHANGE_NOTIFICATION.format(
|
||||
|
||||
@@ -142,6 +142,39 @@ def test_experimental_class_warns_on_instantiation_and_not_on_definition() -> No
|
||||
assert ExperimentalClass.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||
|
||||
|
||||
def test_experimental_abc_subclass_warning_points_at_user_file() -> None:
|
||||
"""Subclassing an experimental ABC must report the warning at the user's
|
||||
``class Sub(...):`` line, not at internal abc.py / <frozen abc> frames.
|
||||
|
||||
Regression: previously the fixed ``stacklevel=3`` landed inside abc.py for
|
||||
ABC-driven class creation, surfacing ``<frozen abc>:106`` to users.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||
class ExperimentalABC(ABC):
|
||||
@abstractmethod
|
||||
def do(self) -> int: ...
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
subclass_line = inspect.currentframe().f_lineno + 1
|
||||
|
||||
class Concrete(ExperimentalABC):
|
||||
def do(self) -> int:
|
||||
return 1
|
||||
|
||||
assert len(caught) == 1
|
||||
assert caught[0].filename == __file__
|
||||
# __init_subclass__ fires at the end of the class body, so the lineno
|
||||
# points somewhere inside the Concrete class definition rather than at
|
||||
# the ``class Concrete`` header itself. The key behaviour we want to
|
||||
# guarantee is that it is in the *user* file at all (not abc.py).
|
||||
assert subclass_line <= caught[0].lineno <= subclass_line + 5
|
||||
assert issubclass(caught[0].category, ExperimentalWarning)
|
||||
assert Concrete().do() == 1
|
||||
|
||||
|
||||
def test_experimental_runtime_checkable_protocol_keeps_protocol_runtime_checks() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@@ -95,8 +95,10 @@ async def test_agent_mode_context_provider_normalizes_custom_modes(
|
||||
)
|
||||
instructions = options["instructions"]
|
||||
assert isinstance(instructions, str)
|
||||
assert '"Draft": Draft it.' in instructions
|
||||
assert '"Final": Finalize it.' in instructions
|
||||
assert "#### Draft" in instructions
|
||||
assert "Draft it." in instructions
|
||||
assert "#### Final" in instructions
|
||||
assert "Finalize it." in instructions
|
||||
assert "You are currently operating in the draft mode." in instructions
|
||||
|
||||
assert (
|
||||
@@ -125,8 +127,8 @@ async def test_agent_mode_context_provider_serializes_tool_outputs_as_json(
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
get_mode_tool = _tool_by_name(tools, "get_mode")
|
||||
set_mode_tool = _tool_by_name(tools, "set_mode")
|
||||
get_mode_tool = _tool_by_name(tools, "mode_get")
|
||||
set_mode_tool = _tool_by_name(tools, "mode_set")
|
||||
|
||||
initial_mode = await get_mode_tool.invoke()
|
||||
assert json.loads(initial_mode[0].text) == {"mode": mode_name}
|
||||
@@ -152,13 +154,13 @@ async def test_agent_mode_context_provider_updates_agent_mode(
|
||||
instructions = options["instructions"]
|
||||
assert isinstance(instructions, str)
|
||||
assert "## Agent Mode" in instructions
|
||||
assert "Use the set_mode tool to switch between modes as your work progresses." in instructions
|
||||
assert "Use the mode_set tool to switch between modes as your work progresses." in instructions
|
||||
assert "ask clarifying questions, discuss options, and get user approval before proceeding" in instructions
|
||||
assert "If you encounter ambiguity, choose the most reasonable option and note your choice" in instructions
|
||||
assert "If you encounter ambiguity" in instructions
|
||||
assert "You are currently operating in the plan mode." in instructions
|
||||
|
||||
get_mode_tool = _tool_by_name(tools, "get_mode")
|
||||
set_mode_tool = _tool_by_name(tools, "set_mode")
|
||||
get_mode_tool = _tool_by_name(tools, "mode_get")
|
||||
set_mode_tool = _tool_by_name(tools, "mode_set")
|
||||
|
||||
initial_mode = await get_mode_tool.invoke()
|
||||
assert json.loads(initial_mode[0].text) == {"mode": "plan"}
|
||||
@@ -218,13 +220,13 @@ async def test_agent_mode_provider_injects_user_message_after_external_change(
|
||||
provider = AgentModeProvider()
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
# First run: agent uses set_mode tool to switch to execute. The tool path must NOT queue a
|
||||
# First run: agent uses mode_set tool to switch to execute. The tool path must NOT queue a
|
||||
# notification because the agent already saw its own tool call in the chat history.
|
||||
_, first_options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Plan first."])],
|
||||
)
|
||||
set_mode_tool = _tool_by_name(first_options["tools"], "set_mode")
|
||||
set_mode_tool = _tool_by_name(first_options["tools"], "mode_set")
|
||||
await set_mode_tool.invoke(arguments={"mode": "execute"})
|
||||
assert "previous_mode_for_notification" not in session.state[provider.source_id]
|
||||
|
||||
|
||||
+128
-3
@@ -27,12 +27,15 @@ from __future__ import annotations
|
||||
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal as _Decimal
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from agent_framework import (
|
||||
@@ -58,6 +61,100 @@ else:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeclarativeEnvConfig:
|
||||
"""Configuration that populates the PowerFx ``Env`` symbol for a workflow.
|
||||
|
||||
Configuration values are always exposed under ``Env.<name>``;
|
||||
``os.environ`` is consulted only when ``restrict_to_configuration``
|
||||
is ``False`` AND the YAML literally references the name in a PowerFx
|
||||
expression (the allowlist enforced via ``referenced_names``).
|
||||
|
||||
Attributes:
|
||||
values: Caller-supplied configuration resolved by name when the
|
||||
workflow YAML references ``=Env.NAME``. Always exposed in
|
||||
the ``Env`` symbol regardless of ``restrict_to_configuration``.
|
||||
restrict_to_configuration: When ``True`` (default), the ``Env``
|
||||
symbol is populated exclusively from ``values``; ``os.environ``
|
||||
is never consulted. Set to ``False`` to additionally fall back
|
||||
to ``os.environ`` for names absent from ``values`` that the
|
||||
workflow YAML explicitly references.
|
||||
referenced_names: The set of ``Env.NAME`` symbols discovered in
|
||||
PowerFx expressions inside the workflow definition. The
|
||||
``os.environ`` fallback is constrained to this allowlist so
|
||||
unrelated environment variables never enter the PowerFx scope.
|
||||
"""
|
||||
|
||||
values: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
|
||||
restrict_to_configuration: bool = True
|
||||
referenced_names: frozenset[str] = field(default_factory=lambda: frozenset[str]())
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Defensive snapshots so the frozen guarantee extends to the
|
||||
# contents of ``values`` / ``referenced_names``: caller mutations
|
||||
# to the original objects after construction cannot leak into
|
||||
# ``resolve()``.
|
||||
object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
|
||||
object.__setattr__(self, "referenced_names", frozenset(self.referenced_names))
|
||||
|
||||
def resolve(self) -> dict[str, str]:
|
||||
"""Return the resolved ``Env`` symbol mapping for the workflow.
|
||||
|
||||
Configuration values are always included (stringified).
|
||||
``os.environ`` is consulted only when ``restrict_to_configuration``
|
||||
is ``False`` and the name appears in ``referenced_names``, so
|
||||
unrelated environment variables never enter the PowerFx scope.
|
||||
Configuration values always win over the environment fallback.
|
||||
"""
|
||||
resolved = {name: str(value) for name, value in self.values.items()}
|
||||
if self.restrict_to_configuration:
|
||||
return resolved
|
||||
for name in self.referenced_names.difference(resolved):
|
||||
env_value = os.environ.get(name)
|
||||
if env_value is not None:
|
||||
resolved[name] = env_value
|
||||
return resolved
|
||||
|
||||
|
||||
def discover_env_references(node: Any) -> set[str]:
|
||||
"""Discover ``Env.NAME`` references in PowerFx expressions inside ``node``.
|
||||
|
||||
Walks any nested ``Mapping``/``list``/scalar structure and inspects every
|
||||
string value. To avoid false positives from doc/description fields that
|
||||
happen to mention ``Env.SOMETHING`` as plain text, the scan only inspects
|
||||
strings that begin with ``=`` (PowerFx expression marker, matching the
|
||||
convention enforced by :meth:`DeclarativeWorkflowState.eval`).
|
||||
|
||||
Args:
|
||||
node: A parsed workflow definition (typically the dict produced by
|
||||
``yaml.safe_load``).
|
||||
|
||||
Returns:
|
||||
The set of ``Env`` identifier names referenced in PowerFx
|
||||
expressions inside ``node``.
|
||||
"""
|
||||
names: set[str] = set()
|
||||
|
||||
def visit(value: Any) -> None:
|
||||
if isinstance(value, str):
|
||||
if value.startswith("="):
|
||||
names.update(_ENV_REFERENCE_RE.findall(value))
|
||||
return
|
||||
if isinstance(value, Mapping):
|
||||
for inner in cast(Mapping[Any, Any], value).values(): # type: ignore[redundant-cast]
|
||||
visit(inner)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in cast(list[Any], value): # type: ignore[redundant-cast]
|
||||
visit(item)
|
||||
|
||||
visit(node)
|
||||
return names
|
||||
|
||||
|
||||
class ConversationData(TypedDict):
|
||||
"""Structure for conversation-related state data.
|
||||
|
||||
@@ -169,13 +266,18 @@ class DeclarativeWorkflowState:
|
||||
- Conversation: Conversation history
|
||||
"""
|
||||
|
||||
def __init__(self, state: State):
|
||||
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
|
||||
"""Initialize with a State instance.
|
||||
|
||||
Args:
|
||||
state: The workflow's state for persistence
|
||||
env_config: Configuration that populates the PowerFx ``Env``
|
||||
symbol when ``_to_powerfx_symbols`` is called. Defaults to
|
||||
an empty configuration which results in no ``Env`` binding,
|
||||
matching the safe default of the :class:`WorkflowFactory`.
|
||||
"""
|
||||
self._state = state
|
||||
self._env_config = env_config if env_config is not None else DeclarativeEnvConfig()
|
||||
|
||||
def initialize(self, inputs: Mapping[str, Any] | None = None) -> None:
|
||||
"""Initialize the declarative state with inputs.
|
||||
@@ -714,6 +816,14 @@ class DeclarativeWorkflowState:
|
||||
# Custom namespaces
|
||||
**state_data.get("Custom", {}),
|
||||
}
|
||||
# Resolve the ``Env`` symbol from the workflow-level
|
||||
# :class:`DeclarativeEnvConfig`. When both ``values`` and the
|
||||
# ``os.environ`` allowlist produce no entries the symbol is
|
||||
# omitted so ``=Env.X`` falls back to the literal expression
|
||||
# string (preserving the legacy "unbound identifier" behaviour).
|
||||
env_bound = self._env_config.resolve()
|
||||
if env_bound:
|
||||
symbols["Env"] = env_bound
|
||||
# Debug log the Local symbols to help diagnose type issues
|
||||
if local_data:
|
||||
for key, value in local_data.items():
|
||||
@@ -867,6 +977,11 @@ class DeclarativeActionExecutor(Executor):
|
||||
action_id = id or action_def.get("id") or f"{action_def.get('kind', 'action')}_{hash(str(action_def)) % 10000}"
|
||||
super().__init__(id=action_id, defer_discovery=True)
|
||||
self._action_def = action_def
|
||||
# The active :class:`DeclarativeEnvConfig` is stamped onto the
|
||||
# executor by :class:`DeclarativeWorkflowBuilder` after construction.
|
||||
# Defaults to an empty configuration so direct ``DeclarativeActionExecutor``
|
||||
# construction (e.g. in unit tests) doesn't expose ``os.environ``.
|
||||
self._declarative_env_config: DeclarativeEnvConfig = DeclarativeEnvConfig()
|
||||
|
||||
# Manually register handlers after initialization
|
||||
self._handlers = {}
|
||||
@@ -874,6 +989,16 @@ class DeclarativeActionExecutor(Executor):
|
||||
self._discover_handlers()
|
||||
self._discover_response_handlers()
|
||||
|
||||
def set_declarative_env_config(self, env_config: DeclarativeEnvConfig) -> None:
|
||||
"""Set the workflow-level :class:`DeclarativeEnvConfig` for this executor.
|
||||
|
||||
Called by :class:`DeclarativeWorkflowBuilder` after each executor is
|
||||
created so that ``_to_powerfx_symbols`` populates the ``Env`` symbol
|
||||
according to the caller-supplied configuration on the
|
||||
:class:`WorkflowFactory`.
|
||||
"""
|
||||
self._declarative_env_config = env_config
|
||||
|
||||
@property
|
||||
def action_def(self) -> dict[str, Any]:
|
||||
"""Get the action definition."""
|
||||
@@ -886,7 +1011,7 @@ class DeclarativeActionExecutor(Executor):
|
||||
|
||||
def _get_state(self, state: State) -> DeclarativeWorkflowState:
|
||||
"""Get the declarative workflow state wrapper."""
|
||||
return DeclarativeWorkflowState(state)
|
||||
return DeclarativeWorkflowState(state, env_config=self._declarative_env_config)
|
||||
|
||||
async def _ensure_state_initialized(
|
||||
self,
|
||||
|
||||
+29
-13
@@ -24,6 +24,7 @@ from agent_framework import (
|
||||
from ._declarative_base import (
|
||||
ConditionResult,
|
||||
DeclarativeActionExecutor,
|
||||
DeclarativeEnvConfig,
|
||||
LoopIterationResult,
|
||||
)
|
||||
from ._errors import DeclarativeWorkflowError
|
||||
@@ -140,6 +141,7 @@ class DeclarativeWorkflowBuilder:
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
env_config: DeclarativeEnvConfig | None = None,
|
||||
):
|
||||
"""Initialize the builder.
|
||||
|
||||
@@ -158,6 +160,10 @@ class DeclarativeWorkflowBuilder:
|
||||
mcp_tool_handler: Handler used to dispatch InvokeMcpTool calls.
|
||||
Must be supplied when the workflow contains any InvokeMcpTool;
|
||||
otherwise build raises ``DeclarativeWorkflowError``.
|
||||
env_config: Optional :class:`DeclarativeEnvConfig` controlling
|
||||
how the ``Env`` PowerFx symbol is populated for every
|
||||
executor built by this builder. Defaults to an empty
|
||||
configuration (``Env`` not exposed).
|
||||
"""
|
||||
self._yaml_def = yaml_definition
|
||||
self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow")
|
||||
@@ -171,6 +177,7 @@ class DeclarativeWorkflowBuilder:
|
||||
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else DeclarativeEnvConfig()
|
||||
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
|
||||
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
|
||||
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
|
||||
@@ -221,6 +228,15 @@ class DeclarativeWorkflowBuilder:
|
||||
# Resolve pending gotos (back-edges for loops, forward-edges for jumps)
|
||||
self._resolve_pending_gotos(builder)
|
||||
|
||||
# Stamp the resolved DeclarativeEnvConfig onto every executor so they
|
||||
# expose the configured Env binding through their _get_state(). This
|
||||
# happens after _create_executors_for_actions and _resolve_pending_gotos
|
||||
# so it covers the entry node, join nodes, evaluators, foreach
|
||||
# init/next/exit nodes, and goto placeholders.
|
||||
for executor in self._executors.values():
|
||||
if isinstance(executor, DeclarativeActionExecutor):
|
||||
executor.set_declarative_env_config(self._env_config)
|
||||
|
||||
return builder.build()
|
||||
|
||||
def _validate_workflow(self, actions: list[dict[str, Any]]) -> None:
|
||||
@@ -817,10 +833,14 @@ class DeclarativeWorkflowBuilder:
|
||||
condition=lambda msg: isinstance(msg, LoopIterationResult) and msg.has_next,
|
||||
)
|
||||
|
||||
# Body exit -> Next (get all exits from body and wire to next_executor)
|
||||
body_exits = self._get_source_exits(body_entry)
|
||||
for body_exit in body_exits:
|
||||
builder.add_edge(source=body_exit, target=next_executor)
|
||||
# Wire from the LAST body action so the loop only advances after the
|
||||
# whole body completes. _get_branch_exit walks the chain, skips
|
||||
# terminators (Break/Continue), and returns nested If/Switch
|
||||
# structures so _get_source_exits can flatten their branch exits.
|
||||
body_exit = self._get_branch_exit(body_entry)
|
||||
if body_exit is not None:
|
||||
for source_exit in self._get_source_exits(body_exit):
|
||||
builder.add_edge(source=source_exit, target=next_executor)
|
||||
|
||||
# Next -> body (when has_next=True, loop back)
|
||||
builder.add_edge(
|
||||
@@ -1008,16 +1028,12 @@ class DeclarativeWorkflowBuilder:
|
||||
return entry.evaluator if is_structure else entry
|
||||
|
||||
def _get_branch_exit(self, branch_entry: Any) -> Any | None:
|
||||
"""Get the exit executor of a branch.
|
||||
"""Get the exit point of a branch for downstream wiring.
|
||||
|
||||
For a linear sequence of actions, returns the last executor.
|
||||
For nested structures, returns None (they have their own branch_exits).
|
||||
|
||||
Args:
|
||||
branch_entry: The first executor of the branch
|
||||
|
||||
Returns:
|
||||
The exit executor, or None if branch is empty or ends with a structure
|
||||
Returns the last executor (or its ``_exit_executor``) for a linear chain,
|
||||
the nested If/Switch structure itself when the chain ends in one (so
|
||||
callers can flatten ``branch_exits`` via :meth:`_get_source_exits`), or
|
||||
``None`` when the branch is empty or ends in a terminator action.
|
||||
"""
|
||||
if branch_entry is None:
|
||||
return None
|
||||
|
||||
@@ -26,6 +26,7 @@ from agent_framework import (
|
||||
)
|
||||
|
||||
from .._loader import AgentFactory
|
||||
from ._declarative_base import DeclarativeEnvConfig, discover_env_references
|
||||
from ._declarative_builder import DeclarativeWorkflowBuilder
|
||||
from ._errors import DeclarativeWorkflowError
|
||||
from ._http_handler import HttpRequestHandler
|
||||
@@ -93,6 +94,8 @@ class WorkflowFactory:
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
configuration: Mapping[str, str] | None = None,
|
||||
restrict_env_to_configuration: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the workflow factory.
|
||||
|
||||
@@ -119,6 +122,23 @@ class WorkflowFactory:
|
||||
for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`,
|
||||
or supply your own implementation to enforce SSRF guards, allowlisting,
|
||||
or auth/connection resolution.
|
||||
configuration: Optional mapping that populates the PowerFx ``Env``
|
||||
symbol referenced from workflow YAML expressions (e.g.
|
||||
``=Env.MY_KEY``). Keys supplied here are always exposed
|
||||
under ``Env.<key>``; the process ``os.environ`` is consulted
|
||||
only when ``restrict_env_to_configuration`` is ``False``.
|
||||
When neither source produces a value the ``Env`` symbol is
|
||||
omitted so ``=Env.X`` evaluates to the literal expression
|
||||
string.
|
||||
restrict_env_to_configuration: When ``True`` (default), the
|
||||
``Env`` PowerFx symbol is populated exclusively from
|
||||
``configuration``; ``os.environ`` is never consulted. Set to
|
||||
``False`` to additionally fall back to ``os.environ`` for
|
||||
names absent from ``configuration`` that the workflow YAML
|
||||
explicitly references. The fallback is constrained to names
|
||||
discovered in PowerFx expressions inside the workflow
|
||||
definition so unrelated environment variables never enter
|
||||
the PowerFx scope.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -151,6 +171,18 @@ class WorkflowFactory:
|
||||
checkpoint_storage=FileCheckpointStorage("./checkpoints"),
|
||||
env_file=".env",
|
||||
)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.declarative import WorkflowFactory
|
||||
|
||||
# Inject named values for =Env.* references in the workflow YAML
|
||||
factory = WorkflowFactory(
|
||||
configuration={
|
||||
"MY_SERVER_URL": "https://example.com",
|
||||
"MY_TOOL_NAME": "search",
|
||||
},
|
||||
)
|
||||
"""
|
||||
self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file)
|
||||
self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {}
|
||||
@@ -160,6 +192,8 @@ class WorkflowFactory:
|
||||
self._max_iterations = max_iterations
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
self._configuration: dict[str, str] = dict(configuration) if configuration else {}
|
||||
self._restrict_env_to_configuration = restrict_env_to_configuration
|
||||
|
||||
def create_workflow_from_yaml_path(
|
||||
self,
|
||||
@@ -394,6 +428,16 @@ class WorkflowFactory:
|
||||
if description:
|
||||
normalized_def["description"] = description
|
||||
|
||||
# Build the DeclarativeEnvConfig from the factory's configuration and the
|
||||
# set of Env references actually used in the workflow PowerFx expressions.
|
||||
# The referenced-name allowlist constrains ``os.environ`` fallback (when
|
||||
# enabled) so unrelated variables never enter the PowerFx scope.
|
||||
env_config = DeclarativeEnvConfig(
|
||||
values=dict(self._configuration),
|
||||
restrict_to_configuration=self._restrict_env_to_configuration,
|
||||
referenced_names=frozenset(discover_env_references(normalized_def)),
|
||||
)
|
||||
|
||||
# Build the graph-based workflow, passing agents and tools for specialized executors
|
||||
try:
|
||||
graph_builder = DeclarativeWorkflowBuilder(
|
||||
@@ -405,6 +449,7 @@ class WorkflowFactory:
|
||||
max_iterations=self._max_iterations,
|
||||
http_request_handler=self._http_request_handler,
|
||||
mcp_tool_handler=self._mcp_tool_handler,
|
||||
env_config=env_config,
|
||||
)
|
||||
workflow = graph_builder.build()
|
||||
except ValueError as e:
|
||||
|
||||
@@ -33,7 +33,7 @@ import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -194,6 +194,21 @@ class DefaultMCPToolHandler:
|
||||
Defaults to ``32``.
|
||||
"""
|
||||
|
||||
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
|
||||
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
|
||||
to the MCP protocol ``tools/list`` discovery operation.
|
||||
|
||||
The constant matches the underlying MCP method name so a single
|
||||
string travels unchanged through host code, YAML, and the protocol
|
||||
wire. When this handler receives an invocation with this name it
|
||||
pages through ``session.list_tools()`` and returns the catalog as a
|
||||
single ``TextContent`` containing JSON of shape
|
||||
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
Workflows can reference this name from an ``InvokeMcpTool`` declarative
|
||||
action to introspect a server's tool surface without an extra round-trip
|
||||
from host code.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -217,10 +232,27 @@ class DefaultMCPToolHandler:
|
||||
self._closed = False
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server."""
|
||||
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server.
|
||||
|
||||
The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is
|
||||
intercepted client-side: instead of being forwarded as a tool call,
|
||||
it is translated to an MCP ``session.list_tools()`` discovery
|
||||
operation (paginated automatically) and returned as a single
|
||||
``TextContent`` containing a JSON tool catalog.
|
||||
"""
|
||||
from agent_framework import Content
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
# Reserved-name args validation runs before connect: rejecting bad
|
||||
# input shouldn't require establishing an MCP session.
|
||||
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME and invocation.arguments:
|
||||
message = f"The reserved MCP '{self.LIST_TOOLS_TOOL_NAME}' operation does not accept tool arguments."
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
try:
|
||||
entry = await self._get_or_create_entry(invocation)
|
||||
except Exception as exc:
|
||||
@@ -240,6 +272,8 @@ class DefaultMCPToolHandler:
|
||||
)
|
||||
|
||||
try:
|
||||
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
|
||||
return await self._invoke_list_tools(entry)
|
||||
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
|
||||
except ToolExecutionException as exc:
|
||||
logger.info(
|
||||
@@ -284,6 +318,59 @@ class DefaultMCPToolHandler:
|
||||
outputs = list(raw)
|
||||
return MCPToolResult(outputs=outputs)
|
||||
|
||||
@staticmethod
|
||||
async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
|
||||
"""Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
|
||||
|
||||
Pages through ``session.list_tools()`` (mirroring the pagination loop
|
||||
in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
|
||||
full catalog as a single ``TextContent`` containing JSON of shape
|
||||
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
|
||||
The output shape, property names, and property order are stable so
|
||||
downstream PowerFx expressions can rely on the schema. ``indent=2``
|
||||
produces human-readable JSON for the conversation log;
|
||||
``allow_nan=False`` guards against producing non-conformant JSON
|
||||
``NaN``/``Infinity`` tokens if a misbehaving server returns such
|
||||
values in a schema.
|
||||
"""
|
||||
from agent_framework import Content
|
||||
|
||||
session = getattr(entry.tool, "session", None)
|
||||
if session is None:
|
||||
message = "MCP session is not connected; cannot list tools."
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
# Lazy import keeps ``mcp`` types out of module import time.
|
||||
from mcp import types as mcp_types
|
||||
|
||||
collected: list[Any] = []
|
||||
params: mcp_types.PaginatedRequestParams | None = None
|
||||
while True:
|
||||
tool_list = await session.list_tools(params=params)
|
||||
collected.extend(tool_list.tools)
|
||||
next_cursor = getattr(tool_list, "nextCursor", None)
|
||||
if not next_cursor:
|
||||
break
|
||||
params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
|
||||
|
||||
payload = {
|
||||
"tools": [
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": tool.inputSchema,
|
||||
"outputSchema": tool.outputSchema,
|
||||
}
|
||||
for tool in collected
|
||||
],
|
||||
}
|
||||
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close all cached MCP clients and the owned httpx clients.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ owned-vs-caller httpx close semantics.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
@@ -33,6 +34,55 @@ pytestmark = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
class FakeListToolsResult: # noqa: B903 - mimics ``mcp.types.ListToolsResult`` shape, not a value type
|
||||
"""Stand-in for ``mcp.types.ListToolsResult`` returned by ``session.list_tools()``."""
|
||||
|
||||
def __init__(self, tools: list[Any], next_cursor: str | None = None) -> None:
|
||||
self.tools = tools
|
||||
self.nextCursor = next_cursor
|
||||
|
||||
|
||||
class FakeMcpTool:
|
||||
"""Stand-in for an MCP ``Tool`` (subset used by ``_invoke_list_tools``)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
inputSchema: dict[str, Any] | None = None,
|
||||
outputSchema: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.inputSchema = inputSchema if inputSchema is not None else {"type": "object", "properties": {}}
|
||||
self.outputSchema = outputSchema
|
||||
|
||||
|
||||
class FakeMcpSession:
|
||||
"""Stand-in for ``mcp.ClientSession``.
|
||||
|
||||
``list_tools_pages`` lets a test enqueue multiple paginated responses;
|
||||
when None (default), an empty single-page result is returned. ``list_tools_error``
|
||||
raises a synthetic error on the next call when set.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.list_tools_pages: list[FakeListToolsResult] | None = None
|
||||
self.list_tools_calls: list[Any] = []
|
||||
self.list_tools_error: BaseException | None = None
|
||||
|
||||
async def list_tools(self, params: Any = None) -> FakeListToolsResult:
|
||||
self.list_tools_calls.append(params)
|
||||
if self.list_tools_error is not None:
|
||||
raise self.list_tools_error
|
||||
if self.list_tools_pages is None:
|
||||
return FakeListToolsResult(tools=[])
|
||||
index = len(self.list_tools_calls) - 1
|
||||
if index >= len(self.list_tools_pages):
|
||||
return FakeListToolsResult(tools=[])
|
||||
return self.list_tools_pages[index]
|
||||
|
||||
|
||||
class FakeTool:
|
||||
"""Stand-in for ``MCPStreamableHTTPTool``.
|
||||
|
||||
@@ -50,6 +100,7 @@ class FakeTool:
|
||||
self.connect_error: BaseException | None = None
|
||||
self.call_handler: Any = lambda **_a: [Content.from_text("ok")]
|
||||
self._httpx_client: httpx.AsyncClient | None = None
|
||||
self.session: FakeMcpSession | None = None
|
||||
# Mimic MCPStreamableHTTPTool: when no caller client AND header_provider
|
||||
# is set, lazily allocate an owned httpx client during connect.
|
||||
FakeTool.instances.append(self)
|
||||
@@ -63,6 +114,9 @@ class FakeTool:
|
||||
# Mimic lazy httpx allocation when no client provided AND header_provider set.
|
||||
if self.kwargs.get("http_client") is None and self.kwargs.get("header_provider") is not None:
|
||||
self._httpx_client = httpx.AsyncClient()
|
||||
# Mimic MCPStreamableHTTPTool: a live session becomes available after connect.
|
||||
if self.session is None:
|
||||
self.session = FakeMcpSession()
|
||||
|
||||
async def close(self) -> None:
|
||||
self.close_count += 1
|
||||
@@ -541,3 +595,185 @@ class TestCacheKey:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "Bearer-A"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "bearer-a"})
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
# ---------- tools/list reserved name --------------------------------------
|
||||
|
||||
|
||||
class TestListTools:
|
||||
"""Exercise the reserved :attr:`DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME` interception path."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_returns_json_catalog(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
# Prime the cache so the FakeTool session exists.
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(
|
||||
tools=[
|
||||
FakeMcpTool(
|
||||
name="search",
|
||||
description="Search docs",
|
||||
inputSchema={"type": "object", "properties": {"q": {"type": "string"}}},
|
||||
outputSchema={"type": "object"},
|
||||
),
|
||||
FakeMcpTool(name="echo", description=None, outputSchema=None),
|
||||
],
|
||||
),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert result.is_error is False
|
||||
assert len(result.outputs) == 1
|
||||
payload = json.loads(result.outputs[0].text) # type: ignore[reportAttributeAccessIssue]
|
||||
assert payload == {
|
||||
"tools": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search docs",
|
||||
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}},
|
||||
"outputSchema": {"type": "object"},
|
||||
},
|
||||
{
|
||||
"name": "echo",
|
||||
"description": None,
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"outputSchema": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_property_order_is_stable(self) -> None:
|
||||
"""JSON property order is stable: name, description, inputSchema, outputSchema."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="t1", description="d")]),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
|
||||
name_idx = text.find('"name"')
|
||||
desc_idx = text.find('"description"')
|
||||
input_idx = text.find('"inputSchema"')
|
||||
output_idx = text.find('"outputSchema"')
|
||||
assert 0 <= name_idx < desc_idx < input_idx < output_idx
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_indented_output(self) -> None:
|
||||
"""Output is JSON with a 2-space indent so the conversation log is human-readable."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="t1")]),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
|
||||
# Indented output contains newlines and a 2-space indented key.
|
||||
assert "\n " in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_rejects_arguments(self) -> None:
|
||||
"""Reserved name does NOT accept tool arguments. Fails fast before connect."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
result = await handler.invoke_tool(
|
||||
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={"q": "test"}),
|
||||
)
|
||||
assert result.is_error is True
|
||||
assert "does not accept tool arguments" in (result.error_message or "")
|
||||
# Args validation runs before connect, so no tool was instantiated.
|
||||
assert FakeTool.instances == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_empty_args_dict_is_accepted(self) -> None:
|
||||
"""An empty arguments dict is equivalent to no arguments."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
result = await handler.invoke_tool(
|
||||
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={}),
|
||||
)
|
||||
assert result.is_error is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_paginates(self) -> None:
|
||||
"""Pagination loop calls list_tools repeatedly until nextCursor is empty."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="a")], next_cursor="cursor1"),
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="b")], next_cursor="cursor2"),
|
||||
FakeListToolsResult(tools=[FakeMcpTool(name="c")], next_cursor=None),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
payload = json.loads(result.outputs[0].text) # type: ignore[reportAttributeAccessIssue]
|
||||
assert [t["name"] for t in payload["tools"]] == ["a", "b", "c"]
|
||||
session = FakeTool.instances[0].session
|
||||
assert session is not None
|
||||
assert len(session.list_tools_calls) == 3
|
||||
# First call has no cursor; second/third use the cursor from the prior page.
|
||||
assert session.list_tools_calls[0] is None
|
||||
assert getattr(session.list_tools_calls[1], "cursor", None) == "cursor1"
|
||||
assert getattr(session.list_tools_calls[2], "cursor", None) == "cursor2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_shares_cache_with_call_tool(self) -> None:
|
||||
"""tools/list reuses the same cached MCP session as a regular call_tool."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(tool_name="search"))
|
||||
await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_propagates_session_errors_as_error_result(self) -> None:
|
||||
"""Errors raised by session.list_tools become MCPToolResult(is_error=True), not crashes."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session.list_tools_error = httpx.ReadTimeout("read timed out") # type: ignore[union-attr]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert result.is_error is True
|
||||
assert "ReadTimeout" in (result.error_message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_returns_error_when_session_is_none(self) -> None:
|
||||
"""If somehow the cached tool has no session, return a clear error rather than crashing."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].session = None
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert result.is_error is True
|
||||
assert "not connected" in (result.error_message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_does_not_call_call_tool(self) -> None:
|
||||
"""The reserved name is intercepted; the inner call_tool path is bypassed."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
call_tool_invoked = False
|
||||
|
||||
def fail(**_a: Any) -> Any:
|
||||
nonlocal call_tool_invoked
|
||||
call_tool_invoked = True
|
||||
raise AssertionError("call_tool should not run for tools/list")
|
||||
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
FakeTool.instances[0].call_handler = fail
|
||||
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr]
|
||||
FakeListToolsResult(tools=[]),
|
||||
]
|
||||
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
|
||||
assert call_tool_invoked is False
|
||||
assert result.is_error is False
|
||||
|
||||
def test_class_attribute_value(self) -> None:
|
||||
# Constant must equal the MCP protocol method name so a single
|
||||
# string travels unchanged through host code, YAML, and the wire.
|
||||
assert DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME == "tools/list"
|
||||
|
||||
@@ -2224,6 +2224,101 @@ class TestBuilderControlFlowCreation:
|
||||
class TestBuilderEdgeWiring:
|
||||
"""Tests for builder edge wiring methods."""
|
||||
|
||||
def test_foreach_advance_edge_wired_from_last_body_action(self):
|
||||
"""Advance edge must come from the last body action."""
|
||||
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "foreach_seq",
|
||||
"actions": [
|
||||
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||
{"kind": "SendActivity", "id": "step_2", "activity": {"text": "two"}},
|
||||
{"kind": "SendActivity", "id": "step_3", "activity": {"text": "three"}},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
|
||||
edges = {(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges}
|
||||
|
||||
assert ("step_3", "loop_next") in edges
|
||||
assert ("step_1", "loop_next") not in edges
|
||||
assert ("step_2", "loop_next") not in edges
|
||||
assert ("step_1", "step_2") in edges
|
||||
assert ("step_2", "step_3") in edges
|
||||
|
||||
def test_foreach_advance_edge_skipped_for_terminator_body(self):
|
||||
"""BreakLoop at end of body wires itself to loop_next; no duplicate edge."""
|
||||
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "foreach_terminator",
|
||||
"actions": [
|
||||
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A"]},
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||
{"kind": "BreakLoop", "id": "stop"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
|
||||
all_edges = [(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges]
|
||||
assert all_edges.count(("stop", "loop_next")) == 1
|
||||
assert ("step_1", "loop_next") not in all_edges
|
||||
|
||||
def test_foreach_advance_edge_with_if_as_last_body_action(self):
|
||||
"""Trailing If in a Foreach body wires every branch exit to loop_next."""
|
||||
from agent_framework_declarative._workflows import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "foreach_if_last",
|
||||
"actions": [
|
||||
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||
{
|
||||
"kind": "If",
|
||||
"id": "check",
|
||||
"condition": '=Local.item = "A"',
|
||||
"then": [
|
||||
{"kind": "SendActivity", "id": "then_action", "activity": {"text": "then"}},
|
||||
],
|
||||
"else": [
|
||||
{"kind": "SendActivity", "id": "else_action", "activity": {"text": "else"}},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
workflow = DeclarativeWorkflowBuilder(yaml_def).build()
|
||||
edges = {(e.source_id, e.target_id) for group in workflow.edge_groups for e in group.edges}
|
||||
|
||||
assert ("then_action", "loop_next") in edges
|
||||
assert ("else_action", "loop_next") in edges
|
||||
assert ("step_1", "loop_next") not in edges
|
||||
|
||||
def test_wire_to_target_with_if_structure(self):
|
||||
"""Test wiring to an If structure routes to evaluator."""
|
||||
from agent_framework import WorkflowBuilder
|
||||
|
||||
@@ -121,6 +121,35 @@ class TestGraphBasedWorkflowExecution:
|
||||
assert "b" in outputs
|
||||
assert "c" in outputs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_foreach_multi_action_body_runs_sequentially(self):
|
||||
"""Body actions must complete per item before advancing."""
|
||||
yaml_def = {
|
||||
"name": "loop_sequential_body",
|
||||
"actions": [
|
||||
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": '="1-" & Local.item'}},
|
||||
{"kind": "SendActivity", "id": "step_2", "activity": {"text": '="2-" & Local.item'}},
|
||||
{"kind": "SendActivity", "id": "step_3", "activity": {"text": '="3-" & Local.item'}},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
workflow = builder.build()
|
||||
|
||||
events = await workflow.run(ActionTrigger())
|
||||
outputs = events.get_outputs()
|
||||
|
||||
assert outputs == ["1-A", "2-A", "3-A", "1-B", "2-B", "3-B"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_with_switch(self):
|
||||
"""Test workflow with Switch/ConditionGroup."""
|
||||
|
||||
@@ -900,7 +900,7 @@ async def test_integration_web_search() -> None:
|
||||
"messages": [
|
||||
Message(
|
||||
role="user",
|
||||
contents=["Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer."],
|
||||
contents=["Where is Microsoft's headquarters? Do a web search to find the answer."],
|
||||
)
|
||||
],
|
||||
"options": {"tool_choice": "auto", "tools": [web_search_tool]},
|
||||
@@ -908,9 +908,7 @@ async def test_integration_web_search() -> None:
|
||||
response = await client.get_response(stream=True, **content).get_final_response()
|
||||
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert "Rumi" in response.text
|
||||
assert "Mira" in response.text
|
||||
assert "Zoey" in response.text
|
||||
assert "redmond" in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
|
||||
@@ -9,8 +9,9 @@ import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Sequence
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from pathlib import Path
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
||||
from typing import Protocol, cast
|
||||
@@ -1505,11 +1506,20 @@ def _convert_message_content(content: MessageContent) -> Content:
|
||||
# region Output Item Conversion
|
||||
|
||||
|
||||
def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
|
||||
def _argument_json_default(value: Any) -> Any:
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
return asdict(value)
|
||||
to_dict = getattr(value, "to_dict", None)
|
||||
if callable(to_dict):
|
||||
return to_dict()
|
||||
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
def _arguments_to_str(arguments: Any | None) -> str:
|
||||
"""Convert arguments to a JSON string.
|
||||
|
||||
Args:
|
||||
arguments: The arguments to convert, can be a string, mapping, or None.
|
||||
arguments: The arguments to convert, can be a string, JSON-like object, or None.
|
||||
|
||||
Returns:
|
||||
The arguments as a JSON string.
|
||||
@@ -1518,7 +1528,7 @@ def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
|
||||
return ""
|
||||
if isinstance(arguments, str):
|
||||
return arguments
|
||||
return json.dumps(arguments)
|
||||
return json.dumps(arguments, default=_argument_json_default)
|
||||
|
||||
|
||||
async def _to_outputs(
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
@@ -405,6 +406,36 @@ class TestStreaming:
|
||||
assert len(args_done) == 1
|
||||
assert args_done[0]["data"]["arguments"] == '{"q": "hello"}'
|
||||
|
||||
async def test_function_call_streaming_serializes_dataclass_arguments(self) -> None:
|
||||
@dataclass
|
||||
class HandoffLikeRequest:
|
||||
agent_response: AgentResponse
|
||||
|
||||
request = HandoffLikeRequest(
|
||||
agent_response=AgentResponse(
|
||||
messages=[Message(role="assistant", contents=[Content.from_text("Need more details")])]
|
||||
)
|
||||
)
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call("call_1", "handoff_to_refund", arguments=request)],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
server = _make_server(agent)
|
||||
resp = await _post(server, stream=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
|
||||
assert len(args_done) == 1
|
||||
|
||||
payload = json.loads(args_done[0]["data"]["arguments"])
|
||||
assert payload["agent_response"]["type"] == "agent_response"
|
||||
assert payload["agent_response"]["messages"][0]["contents"][0]["text"] == "Need more details"
|
||||
|
||||
async def test_alternating_text_and_function_call(self) -> None:
|
||||
agent = _make_agent(
|
||||
stream_updates=[
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user