mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
536a998b59 | ||
|
|
401a552735 | ||
|
|
718a1f14fd | ||
|
|
f7c5b8d108 | ||
|
|
e6762ea876 | ||
|
|
08abe9e704 | ||
|
|
a84ad42f6d | ||
|
|
ded17b178c | ||
|
|
55dc3ce734 | ||
|
|
9d8e5ca4f5 | ||
|
|
af787569b3 | ||
|
|
3db2004e49 | ||
|
|
efdabd56dc | ||
|
|
371a869e44 | ||
|
|
e532ced950 |
@@ -174,6 +174,7 @@
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Agent_Step26_FoundryToolboxMcpSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
||||
@@ -604,6 +605,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.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" />
|
||||
|
||||
@@ -20,14 +20,17 @@
|
||||
"src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
|
||||
"src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj",
|
||||
"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.Mcp\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.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",
|
||||
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.7.0</VersionPrefix>
|
||||
<VersionPrefix>1.8.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260526</DateSuffix>
|
||||
<DateSuffix>260528</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.7.0</GitTag>
|
||||
<GitTag>1.8.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox MCP Skills.
|
||||
//
|
||||
// Uses AgentSkillsProviderBuilder to discover MCP-based skills from a Foundry
|
||||
// Toolbox endpoint and inject them as AIContextProviders so the agent can
|
||||
// discover and use them at runtime.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
// --- Configuration ---
|
||||
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";
|
||||
string toolboxMcpServerUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL")
|
||||
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_MCP_SERVER_URL is not set.");
|
||||
|
||||
// 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.
|
||||
TokenCredential credential = new DefaultAzureCredential();
|
||||
|
||||
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
|
||||
{
|
||||
InnerHandler = new HttpClientHandler(),
|
||||
});
|
||||
|
||||
// --- Connect to the Foundry Toolbox MCP endpoint ---
|
||||
await using McpClient mcpClient = await McpClient.CreateAsync(
|
||||
new HttpClientTransport(
|
||||
new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(toolboxMcpServerUrl),
|
||||
Name = "foundry_toolbox",
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
AdditionalHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Foundry-Features"] = "Toolboxes=V1Preview",
|
||||
},
|
||||
},
|
||||
httpClient));
|
||||
|
||||
// --- Discover MCP-based skills ---
|
||||
var skillsProvider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(mcpClient)
|
||||
.Build();
|
||||
|
||||
// --- Create the agent ---
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
|
||||
|
||||
AIAgent agent = aiProjectClient.AsAIAgent(
|
||||
options: new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ToolboxMcpSkillsAgent",
|
||||
ChatOptions = new()
|
||||
{
|
||||
ModelId = deploymentName,
|
||||
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
});
|
||||
|
||||
// --- Interactive prompt ---
|
||||
Console.Write("User: ");
|
||||
string? query = Console.ReadLine();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
Console.WriteLine("No input provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Assistant: {await agent.RunAsync(query)}");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DelegatingHandler: attaches a fresh Foundry bearer token to every request
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
|
||||
{
|
||||
private readonly TokenRequestContext _tokenContext = new([scope]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Foundry Toolbox MCP Skills
|
||||
|
||||
This sample uses
|
||||
`AgentSkillsProviderBuilder` to discover MCP-based skills from a Foundry Toolbox endpoint
|
||||
and inject them as `AIContextProviders` so the agent can discover and use them at runtime.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
|
||||
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
|
||||
- Using `AgentSkillsProviderBuilder.UseMcpSkills(client)` to discover skills from the toolbox
|
||||
- Injecting the discovered skills into `AIProjectClient.AsAIAgent(...)` via `AIContextProviders`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project with a toolbox already configured
|
||||
- The toolbox MCP endpoint must expose `skill://index.json` with `skill-md` entries (SEP-2640). If the resource is absent, the sample runs but the skills provider will be empty.
|
||||
- Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
|
||||
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
|
||||
$env:FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-foundry-service.services.ai.azure.com/api/projects/your-project/toolboxes/your-toolbox/mcp?api-version=v1"
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -74,6 +74,7 @@ Some samples require extra tool-specific environment variables. See each sample
|
||||
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
|
||||
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
|
||||
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
|
||||
| [Foundry toolbox MCP skills](./Agent_Step26_FoundryToolboxMcpSkills/) | Use a Foundry Toolbox with MCP-based skills discovery (SEP-2640) via AIContextProviders |
|
||||
|
||||
## Running the samples
|
||||
|
||||
|
||||
@@ -72,6 +72,95 @@ public static class AnsiEscapes
|
||||
/// </summary>
|
||||
public static string ResetAttributes => "\x1b[0m";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the visible (printed) length of a string after stripping ANSI escape sequences.
|
||||
/// Escape sequences are zero-width on screen but occupy characters in the raw string.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji,
|
||||
/// combining characters, variation selectors, and East Asian wide characters may be
|
||||
/// measured incorrectly. For the console harness this is acceptable since content is
|
||||
/// predominantly ASCII, and emoji are padded with surrounding spaces.
|
||||
/// </remarks>
|
||||
public static int VisibleLength(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int length = 0;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[')
|
||||
{
|
||||
// Skip the ESC[ and all characters up to and including the final byte (0x40–0x7E).
|
||||
i += 2;
|
||||
while (i < text.Length && text[i] < 0x40)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
// i now points to the final byte of the escape sequence; the for-loop will advance past it.
|
||||
}
|
||||
else if (text[i] != '\n' && text[i] != '\r')
|
||||
{
|
||||
length++;
|
||||
}
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of physical terminal rows a text item will occupy,
|
||||
/// accounting for both explicit newlines and terminal line wrapping.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to measure.</param>
|
||||
/// <param name="terminalWidth">The terminal width in columns. If <= 0, wrapping is ignored (1 row per logical line).</param>
|
||||
/// <returns>The number of physical rows the text occupies.</returns>
|
||||
public static int CountPhysicalLines(string text, int terminalWidth)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int physicalLines = 0;
|
||||
int lineStart = 0;
|
||||
|
||||
for (int i = 0; i <= text.Length; i++)
|
||||
{
|
||||
if (i == text.Length || text[i] == '\n')
|
||||
{
|
||||
if (terminalWidth <= 0)
|
||||
{
|
||||
// No wrapping — each logical line is one physical row
|
||||
physicalLines += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
string logicalLine = text[lineStart..i];
|
||||
int visibleWidth = VisibleLength(logicalLine);
|
||||
|
||||
physicalLines += visibleWidth == 0
|
||||
? 1
|
||||
: (visibleWidth - 1) / terminalWidth + 1;
|
||||
}
|
||||
|
||||
lineStart = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
physicalLines--;
|
||||
}
|
||||
|
||||
return physicalLines;
|
||||
}
|
||||
|
||||
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
|
||||
{
|
||||
ConsoleColor.Black => 30,
|
||||
|
||||
@@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps
|
||||
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in lines) needed to render all items.
|
||||
/// Calculates the height (in lines) needed to render all items,
|
||||
/// accounting for terminal line wrapping at the specified width.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to measure.</param>
|
||||
/// <returns>The total number of lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<string> items)
|
||||
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
|
||||
/// <returns>The total number of physical lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<string> items, int terminalWidth = 0)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
total += CountLines(items[i]);
|
||||
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
|
||||
}
|
||||
|
||||
return total;
|
||||
@@ -47,13 +49,20 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
{
|
||||
string text = props.Items[i];
|
||||
string[] lines = text.Split('\n');
|
||||
int lineCount = CountLines(text);
|
||||
int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width);
|
||||
int itemRow = 0;
|
||||
|
||||
for (int j = 0; j < lineCount; j++)
|
||||
for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++)
|
||||
{
|
||||
int linePhysicalRows = props.Width > 0
|
||||
? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1)
|
||||
: 1;
|
||||
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
|
||||
Console.Write(lines[j]);
|
||||
currentRow++;
|
||||
|
||||
currentRow += linePhysicalRows;
|
||||
itemRow += linePhysicalRows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,29 +75,4 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,36 +77,12 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
Console.Write(props.Items[i]);
|
||||
}
|
||||
|
||||
// Calculate the offset from bottom for the start of the new last item
|
||||
int lastItemLines = CountLines(props.Items[^1]);
|
||||
// Calculate the offset from bottom for the start of the new last item,
|
||||
// accounting for terminal line wrapping at the available width.
|
||||
int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -13,6 +13,11 @@ public abstract class ConsoleReactiveComponent
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving.
|
||||
/// </summary>
|
||||
protected static object RenderLock { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
|
||||
/// Used by parent components to set layout (X, Y, Width, Height) on children without
|
||||
@@ -40,7 +45,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
where TProps : ConsoleReactiveProps
|
||||
where TState : ConsoleReactiveState
|
||||
{
|
||||
private readonly object _renderLock = new();
|
||||
private TProps? _lastRenderedProps;
|
||||
private TState? _lastRenderedState;
|
||||
|
||||
@@ -74,7 +78,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
/// </summary>
|
||||
public override void Render()
|
||||
{
|
||||
lock (this._renderLock)
|
||||
lock (RenderLock)
|
||||
{
|
||||
if (this.Props is null)
|
||||
{
|
||||
@@ -97,7 +101,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
/// <inheritdoc/>
|
||||
public override void Invalidate()
|
||||
{
|
||||
lock (this._renderLock)
|
||||
lock (RenderLock)
|
||||
{
|
||||
this._lastRenderedProps = default;
|
||||
this._lastRenderedState = default;
|
||||
|
||||
@@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
private int _scrollRegionBottom;
|
||||
private bool _resizedSinceLastRender = true;
|
||||
private bool _deactivated;
|
||||
private BottomPanelMode _lastRenderedBottomPanelMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
|
||||
@@ -341,7 +342,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
}
|
||||
|
||||
// Calculate queued items panel height
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth);
|
||||
|
||||
// Build the bottom panel child based on mode
|
||||
ConsoleReactiveComponent bottomChild;
|
||||
@@ -406,6 +407,14 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
|
||||
// When the bottom panel mode changes, the new child must repaint even if its
|
||||
// props haven't changed — the screen area was overwritten by the previous child.
|
||||
if (state.Mode != this._lastRenderedBottomPanelMode)
|
||||
{
|
||||
bottomChild.Invalidate();
|
||||
this._lastRenderedBottomPanelMode = state.Mode;
|
||||
}
|
||||
|
||||
var ruleProps = new TopBottomRuleProps
|
||||
{
|
||||
Width = state.ConsoleWidth,
|
||||
|
||||
+7
-5
@@ -88,16 +88,17 @@ public sealed class PlanningOutputObserver : ConsoleObserver
|
||||
{
|
||||
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
catch (JsonException)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
|
||||
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
|
||||
// JSON parsing failed — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (planningResponse is null)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
|
||||
// Null result — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -118,7 +119,8 @@ public sealed class PlanningOutputObserver : ConsoleObserver
|
||||
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
|
||||
// Unexpected type — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -7,20 +7,20 @@ using Microsoft.Extensions.AI;
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
|
||||
/// Formats <c>todos_*</c> tool calls with tree-view output for added items
|
||||
/// and structured output for complete/remove operations.
|
||||
/// </summary>
|
||||
public sealed class TodoToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("todos_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"TodoList_Add" => FormatAddTodos(call),
|
||||
"TodoList_Complete" => FormatCompleteTodos(call),
|
||||
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
|
||||
"todos_add" => FormatAddTodos(call),
|
||||
"todos_complete" => FormatCompleteTodos(call),
|
||||
"todos_remove" => FormatIdList(call, "ids", "Remove"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
+88
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using System.Text.Json;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -53,9 +54,94 @@ public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
|
||||
|
||||
case StreamingResponseIncompleteUpdate incompleteUpdate:
|
||||
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
|
||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
||||
if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string detail = GetContentFilterDetails(incompleteUpdate);
|
||||
const string Message = "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short.";
|
||||
await ux.WriteInfoLineAsync(
|
||||
string.IsNullOrEmpty(detail) ? Message : $"{Message}\n{detail}",
|
||||
ConsoleColor.Yellow);
|
||||
}
|
||||
else
|
||||
{
|
||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts content filter details from the serialized response JSON and returns
|
||||
/// a formatted string showing which specific categories were triggered.
|
||||
/// Returns <see cref="string.Empty"/> if details cannot be extracted.
|
||||
/// </summary>
|
||||
private static string GetContentFilterDetails(StreamingResponseIncompleteUpdate incompleteUpdate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(incompleteUpdate);
|
||||
using var doc = JsonDocument.Parse(data.ToString());
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Navigate into the nested response object if present.
|
||||
JsonElement responseElement = root.TryGetProperty("response", out var resp) ? resp : root;
|
||||
|
||||
if (!responseElement.TryGetProperty("content_filters", out var filtersArray)
|
||||
|| filtersArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
foreach (var filter in filtersArray.EnumerateArray())
|
||||
{
|
||||
if (!filter.TryGetProperty("content_filter_results", out var results)
|
||||
|| results.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect category data for aligned output.
|
||||
var categories = new List<(string Name, bool Filtered, string? Severity)>();
|
||||
foreach (var category in results.EnumerateObject())
|
||||
{
|
||||
if (category.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool filtered = category.Value.TryGetProperty("filtered", out var f) && f.GetBoolean();
|
||||
string? severity = category.Value.TryGetProperty("severity", out var s) ? s.GetString() : null;
|
||||
categories.Add((category.Name, filtered, severity));
|
||||
}
|
||||
|
||||
// Build all category lines into a single string.
|
||||
int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0;
|
||||
var lines = new List<string>();
|
||||
|
||||
foreach (var (name, filtered, severity) in categories)
|
||||
{
|
||||
string paddedName = name.PadRight(maxNameLen);
|
||||
string icon = filtered ? "❌" : "✅";
|
||||
string statusText = filtered ? "Filtered " : "Not Filtered";
|
||||
string severityText = severity is not null ? $" Severity: {severity}" : "";
|
||||
|
||||
lines.Add($" {icon} {paddedName} {statusText}{severityText}");
|
||||
}
|
||||
|
||||
if (lines.Count > 0)
|
||||
{
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Parsing not critical — skip silently if it fails.
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ else
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
builder.AddA2AServer(hostA2AAgent);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -49,6 +49,10 @@ var agent = new AzureOpenAIClient(
|
||||
AGUIServerSerializerContext.Default.Options)
|
||||
]);
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// Register the agent with the host and configure it to use an in-memory session store
|
||||
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
|
||||
builder
|
||||
|
||||
@@ -28,6 +28,10 @@ builder.AddDevUI();
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate",
|
||||
instructions: "You are a pirate. Speak like a pirate",
|
||||
@@ -148,6 +152,10 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapOpenApi();
|
||||
|
||||
+1
@@ -27,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -28,6 +28,23 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The A2A <c>contextId</c> arrives from the wire
|
||||
/// and is treated as a chain-resume identifier — <em>not</em> as an authorization
|
||||
/// token. The <see cref="AgentSessionStore"/> contract carries no principal/owner
|
||||
/// dimension, so when a persistent store is registered any caller who knows or
|
||||
/// guesses another caller's <c>contextId</c> can resume that other caller's
|
||||
/// persisted thread. Hosts that serve more than one user must compose a principal
|
||||
/// dimension into the lookup key — typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>). When no isolation provider is
|
||||
/// registered, behavior is unchanged — the bare <c>contextId</c> is used as the
|
||||
/// conversation identifier, which is appropriate for first-run / single-user /
|
||||
/// prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
@@ -46,6 +63,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -65,6 +89,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -83,6 +114,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -114,6 +152,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -140,9 +185,17 @@ public static class A2AServerServiceCollectionExtensions
|
||||
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
|
||||
|
||||
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
|
||||
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
|
||||
}
|
||||
|
||||
var hostAgent = new AIHostAgent(
|
||||
innerAgent: agent,
|
||||
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
|
||||
sessionStore: agentSessionStore);
|
||||
|
||||
agentHandler = new A2AAgentHandler(hostAgent, runMode);
|
||||
}
|
||||
|
||||
+20
@@ -73,6 +73,26 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
/// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the
|
||||
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The AG-UI <c>RunAgentInput.ThreadId</c> arrives
|
||||
/// from the wire and is treated as a chain-resume identifier — <em>not</em> as an
|
||||
/// authorization token. The <see cref="AgentSessionStore"/> contract carries no
|
||||
/// principal/owner dimension, so when a persistent store is registered any caller
|
||||
/// who knows or guesses another caller's <c>ThreadId</c> can resume that other
|
||||
/// caller's persisted thread. Hosts that serve more than one user must compose a
|
||||
/// principal dimension into the lookup key. The recommended way is to wrap the
|
||||
/// keyed <see cref="AgentSessionStore"/> in
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
|
||||
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
|
||||
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
|
||||
/// isolation provider is registered, behavior is unchanged — the bare
|
||||
/// <c>ThreadId</c> is used as the conversation identifier, which is appropriate
|
||||
/// for first-run / single-user / prototyping scenarios but unsafe for
|
||||
/// multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapAGUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
|
||||
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider is suitable for ASP.NET Core web applications where session isolation is based on
|
||||
/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
|
||||
/// from the ambient <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
|
||||
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// will then enforce strict or pass-through behavior based on its configuration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
|
||||
/// to provide access to the current <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||||
private readonly string _claimType;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">
|
||||
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
|
||||
/// </exception>
|
||||
public ClaimsIdentitySessionIsolationKeyProvider(
|
||||
IHttpContextAccessor? httpContextAccessor,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
|
||||
this._httpContextAccessor = httpContextAccessor;
|
||||
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the session isolation key from the current user's claims.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the value of the
|
||||
/// configured claim type from the current user's identity, or <see langword="null"/> if the claim
|
||||
/// is not present or the HTTP context is unavailable.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
|
||||
/// of the specified type exist, the first match is returned.
|
||||
/// </remarks>
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Claim? claim = this._httpContextAccessor?
|
||||
.HttpContext?
|
||||
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
|
||||
return new ValueTask<string?>(claim?.Value);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the claim type to extract from the user's identity for session isolation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
|
||||
/// the user's name or unique identifier claim.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common alternatives include:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
|
||||
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.AspNetCore</RootNamespace>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn)</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Hosting ASP.NET Core</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for hosting agents in an ASP.NET Core context.</Description>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring AI hosting services in an <see cref="IServiceCollection"/>.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
|
||||
/// to generate session isolation keys.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
|
||||
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
|
||||
/// <remarks>
|
||||
/// This method requires <see cref="IHttpContextAccessor"/> to be registered in the service collection.
|
||||
/// Ensure that <c>services.AddHttpContextAccessor()</c> has been called before using this method.
|
||||
/// </remarks>
|
||||
public static IServiceCollection UseClaimsBasedSessionIsolation(
|
||||
this IServiceCollection services,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new();
|
||||
ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
|
||||
services.Add(descriptor);
|
||||
|
||||
return services;
|
||||
|
||||
object CreateIsolationKeyProvider(IServiceProvider serviceProvider)
|
||||
{
|
||||
IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
|
||||
|
||||
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
@@ -9,9 +11,39 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// Defines the contract for storing and retrieving agent conversation threads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementations of this interface enable persistent storage of conversation threads,
|
||||
/// allowing conversations to be resumed across HTTP requests, application restarts,
|
||||
/// or different service instances in hosted scenarios.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The <c>conversationId</c> passed to
|
||||
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> typically originates
|
||||
/// from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an A2A
|
||||
/// <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
|
||||
/// token, and the <c>(agent, conversationId)</c> tuple carries no principal/owner
|
||||
/// dimension. Hosts that serve more than one user from the same registered store must
|
||||
/// therefore compose a principal dimension into the lookup key, otherwise any caller
|
||||
/// who knows or guesses another caller's <c>conversationId</c> can resume
|
||||
/// that other caller's persisted thread. The framework provides
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
|
||||
/// <c>conversationId</c> to include an isolation key resolved from a
|
||||
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
|
||||
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
|
||||
/// store behaves as a single-namespace persistence layer — appropriate for
|
||||
/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Implementer guidance.</strong> Implementations should treat
|
||||
/// <c>conversationId</c> as opaque: do not parse it, do not impose length
|
||||
/// or character-set constraints on it, and do not assume it round-trips to the value
|
||||
/// the caller originally supplied (decorators such as
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
|
||||
/// Be aware that any logging, telemetry, or audit sink that surfaces
|
||||
/// <c>conversationId</c> will also surface the isolation prefix when a
|
||||
/// scoping decorator is in the chain.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class AgentSessionStore
|
||||
{
|
||||
@@ -43,4 +75,35 @@ public abstract class AgentSessionStore
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
|
||||
/// <param name="serviceType">The type of object being requested.</param>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AgentSessionStore"/>,
|
||||
/// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
|
||||
/// to verify that specific store implementations are present.
|
||||
/// </remarks>
|
||||
public virtual object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
_ = Throw.IfNull(serviceType);
|
||||
|
||||
return serviceKey is null && serviceType.IsInstanceOfType(this)
|
||||
? this
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of type <typeparamref name="TService"/>.</summary>
|
||||
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
|
||||
/// <remarks>
|
||||
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AgentSessionStore"/>,
|
||||
/// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
|
||||
/// to verify that specific store implementations are present.
|
||||
/// </remarks>
|
||||
public TService? GetService<TService>(object? serviceKey = null)
|
||||
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for agent session stores that delegate operations to an inner store
|
||||
/// instance while allowing for extensibility and customization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="DelegatingAgentSessionStore"/> implements the decorator pattern for <see cref="AgentSessionStore"/>s,
|
||||
/// enabling the creation of pipelines where each layer can add functionality while delegating core operations to an
|
||||
/// underlying store.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default implementation provides transparent pass-through behavior, forwarding all operations to the inner store.
|
||||
/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the store
|
||||
/// interface.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class DelegatingAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStore"/> class with the specified inner
|
||||
/// store.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying session store instance that will handle the core operations.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// The inner session store serves as the foundation of the delegation chain. All operations not overridden by
|
||||
/// derived classes will be forwarded to this store.
|
||||
/// </remarks>
|
||||
protected DelegatingAgentSessionStore(AgentSessionStore innerStore)
|
||||
{
|
||||
this.InnerStore = Throw.IfNull(innerStore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inner session store instance that receives delegated operations.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The underlying <see cref="AgentSessionStore"/> instance that handles core storage operations.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Derived classes can use this property to access the inner session store for custom delegation scenarios
|
||||
/// or to forward operations with additional processing.
|
||||
/// </remarks>
|
||||
protected AgentSessionStore InnerStore { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// This implementation first checks if this instance satisfies the service request.
|
||||
/// If not, it chains the request to the inner store, allowing services to be retrieved
|
||||
/// from any store in the delegation chain.
|
||||
/// </remarks>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
// First, check if this instance satisfies the request
|
||||
object? service = base.GetService(serviceType, serviceKey);
|
||||
if (service is not null)
|
||||
{
|
||||
return service;
|
||||
}
|
||||
|
||||
// Chain to the inner store
|
||||
return this.InnerStore.GetService(serviceType, serviceKey);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
@@ -16,12 +17,11 @@ public static class HostedAgentBuilderExtensions
|
||||
/// Configures the host agent builder to use an in-memory session store for agent session management.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the in-memory session store.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same <paramref name="builder"/> instance, configured to use an in-memory session store.</returns>
|
||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton<AgentSessionStore>(builder.Name, new InMemoryAgentSessionStore());
|
||||
return builder;
|
||||
}
|
||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true)
|
||||
=> builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation);
|
||||
|
||||
/// <summary>
|
||||
/// Registers the specified agent session store with the host agent builder, enabling session-specific storage for
|
||||
@@ -29,12 +29,11 @@ public static class HostedAgentBuilderExtensions
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the session store. Cannot be null.</param>
|
||||
/// <param name="store">The agent session store instance to register. Cannot be null.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same host agent builder instance, allowing for method chaining.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton(builder.Name, store);
|
||||
return builder;
|
||||
}
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true)
|
||||
=> builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation);
|
||||
|
||||
/// <summary>
|
||||
/// Configures the host agent builder to use a custom session store implementation for agent sessions.
|
||||
@@ -44,16 +43,36 @@ public static class HostedAgentBuilderExtensions
|
||||
/// name.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
|
||||
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
Throw.IfNullOrEmpty(keyString);
|
||||
return createAgentSessionStore(sp, keyString) ??
|
||||
|
||||
AgentSessionStore store = createAgentSessionStore(sp, keyString) ??
|
||||
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
|
||||
|
||||
if (withIsolation && store.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
|
||||
|
||||
// Best efforts options getting
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
|
||||
if (options is null)
|
||||
{
|
||||
var optionsProvider = sp.GetService<IOptions<IsolationKeyScopedAgentSessionStoreOptions>>();
|
||||
options = optionsProvider?.Value;
|
||||
}
|
||||
|
||||
store = new IsolationKeyScopedAgentSessionStore(store, isolationKeyProvider, options ?? new());
|
||||
}
|
||||
|
||||
return store;
|
||||
}, lifetime);
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
|
||||
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
|
||||
/// per logical partition (e.g., user, tenant, or composite key).
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
{
|
||||
private readonly SessionIsolationKeyProvider? _keyProvider;
|
||||
private readonly bool _strict;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
|
||||
/// <param name="keyProvider">
|
||||
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="innerStore"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public IsolationKeyScopedAgentSessionStore(
|
||||
AgentSessionStore innerStore,
|
||||
SessionIsolationKeyProvider? keyProvider,
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = null)
|
||||
: base(innerStore)
|
||||
{
|
||||
this._keyProvider = keyProvider;
|
||||
options ??= new IsolationKeyScopedAgentSessionStoreOptions();
|
||||
this._strict = options.Strict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The isolation key string, or <see langword="null"/> if no key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The provider returned <see langword="null"/> and strict mode is enabled.
|
||||
/// </exception>
|
||||
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = this._keyProvider != null
|
||||
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
if (this._strict && key == null)
|
||||
{
|
||||
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
|
||||
/// </summary>
|
||||
/// <param name="key">The raw isolation key.</param>
|
||||
/// <returns>The escaped isolation key.</returns>
|
||||
/// <remarks>
|
||||
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
|
||||
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
|
||||
/// </remarks>
|
||||
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
|
||||
/// </summary>
|
||||
/// <param name="bareConversationId">The original conversation ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID
|
||||
/// if no isolation key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (key == null)
|
||||
{
|
||||
return bareConversationId;
|
||||
}
|
||||
|
||||
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
|
||||
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <see langword="false"/>, the conversation ID is passed through unmodified when the isolation key is absent,
|
||||
/// allowing unscoped access to the underlying session store. This mode is suitable for development scenarios
|
||||
/// or mixed environments where not all requests have isolation keys.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool Strict { get; set; } = true;
|
||||
}
|
||||
@@ -24,6 +24,20 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
|
||||
/// such as Redis, SQL Server, or Azure Cosmos DB.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Multi-user warning.</strong> This store keys threads by
|
||||
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
|
||||
/// the conversation identifier originates from the wire (for example, an AG-UI
|
||||
/// <c>RunAgentInput.ThreadId</c> or an A2A <c>contextId</c>), any caller who knows
|
||||
/// or guesses another caller's identifier can resume that other caller's persisted
|
||||
/// thread. Multi-user hosts must wrap this store in
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
|
||||
/// scoped per principal. See the trust-model remarks on
|
||||
/// <see cref="AgentSessionStore"/> for the full background.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage
|
||||
/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes
|
||||
/// implement the key resolution logic appropriate to their hosting environment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
|
||||
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
|
||||
/// to unscoped storage based on its configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class SessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the session isolation key for the current request or execution context.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the isolation key string,
|
||||
/// or <see langword="null"/> if no key is available in the current context.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims,
|
||||
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
|
||||
/// the caller to decide on strict vs. pass-through behavior.
|
||||
/// </remarks>
|
||||
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -26,11 +26,11 @@ namespace Microsoft.Agents.AI;
|
||||
/// <para>
|
||||
/// This provider exposes the following tools to the agent:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>TodoList_Add</c> — Add one or more todo items, each with a title and optional description.</description></item>
|
||||
/// <item><description><c>TodoList_Complete</c> — Mark one or more todo items as complete by their IDs.</description></item>
|
||||
/// <item><description><c>TodoList_Remove</c> — Remove one or more todo items by their IDs.</description></item>
|
||||
/// <item><description><c>TodoList_GetRemaining</c> — Retrieve only incomplete todo items.</description></item>
|
||||
/// <item><description><c>TodoList_GetAll</c> — Retrieve all todo items (complete and incomplete).</description></item>
|
||||
/// <item><description><c>todos_add</c> — Add one or more todo items, each with a title and optional description.</description></item>
|
||||
/// <item><description><c>todos_complete</c> — Mark one or more todo items as complete by their IDs and reasons.</description></item>
|
||||
/// <item><description><c>todos_remove</c> — Remove one or more todo items by their IDs.</description></item>
|
||||
/// <item><description><c>todos_get_remaining</c> — Retrieve only incomplete todo items.</description></item>
|
||||
/// <item><description><c>todos_get_all</c> — Retrieve all todo items (complete and incomplete).</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -53,11 +53,11 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant/old items or adding new ones as needed.
|
||||
|
||||
Use these tools to manage your tasks:
|
||||
- Use TodoList_Add to break down complex work into trackable items (supports adding one or many at once).
|
||||
- Use TodoList_Complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
|
||||
- Use TodoList_GetRemaining to check what work is still pending.
|
||||
- Use TodoList_GetAll to review the full list including completed items.
|
||||
- Use TodoList_Remove to remove items that are no longer needed (supports one or many at once).
|
||||
- Use todos_add to break down complex work into trackable items (supports adding one or many at once).
|
||||
- Use todos_complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
|
||||
- Use todos_get_remaining to check what work is still pending.
|
||||
- Use todos_get_all to review the full list including completed items.
|
||||
- Use todos_remove to remove items that are no longer needed (supports one or many at once).
|
||||
""";
|
||||
|
||||
private readonly ProviderSessionState<TodoState> _sessionState;
|
||||
@@ -229,7 +229,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Add",
|
||||
Name = "todos_add",
|
||||
Description = "Add one or more todo items. Each item has a title and an optional description. Returns the list of created todo items.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -267,7 +267,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Complete",
|
||||
Name = "todos_complete",
|
||||
Description = "Mark one or more todo items as complete. Each entry has an ID and a reason describing how/why the item was completed. Returns the number of items that were found and marked complete.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -297,7 +297,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Remove",
|
||||
Name = "todos_remove",
|
||||
Description = "Remove one or more todo items by their IDs. Returns the number of items that were found and removed.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -319,7 +319,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_GetRemaining",
|
||||
Name = "todos_get_remaining",
|
||||
Description = "Retrieve the list of incomplete todo items.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -341,7 +341,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_GetAll",
|
||||
Name = "todos_get_all",
|
||||
Description = "Retrieve the full list of todo items, both complete and incomplete.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ public sealed class SessionPersistenceTests : IAsyncDisposable
|
||||
|
||||
// Register agent using hosting DI pattern with InMemorySessionStore
|
||||
builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name))
|
||||
.WithInMemorySessionStore();
|
||||
.WithInMemorySessionStore(withIsolation: false);
|
||||
|
||||
this._app = builder.Build();
|
||||
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
{
|
||||
private const string TestUserId = "test-user-id";
|
||||
private const string CustomClaimType = "custom-claim-type";
|
||||
private const string CustomClaimValue = "custom-claim-value";
|
||||
|
||||
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
|
||||
/// </summary>
|
||||
public ClaimsIdentitySessionIsolationKeyProviderTests()
|
||||
{
|
||||
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor accepts null IHttpContextAccessor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotEmpty()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotWhitespace()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionIsolationKeyAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TestUserId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType });
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(CustomClaimValue, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim("other-claim", "value");
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor returns null HttpContext.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor itself is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string FirstValue = "first-value";
|
||||
const string SecondValue = "second-value";
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue),
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal
|
||||
};
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(FirstValue, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupHttpContextWithClaim(string claimType, string claimValue)
|
||||
{
|
||||
var claims = new[] { new Claim(claimType, claimValue) };
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal
|
||||
};
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DelegatingAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
public class DelegatingAgentSessionStoreTests
|
||||
{
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly TestDelegatingAgentSessionStore _delegatingStore;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public DelegatingAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
// Setup inner store mock
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () => new TestDelegatingAgentSessionStore(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor sets the inner store correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithValidInnerStore_SetsInnerStore()
|
||||
{
|
||||
// Act
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Method Delegation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act
|
||||
var session = await this._delegatingStore.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, session);
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
var expectedSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<AgentSession>(s => s == expectedSession),
|
||||
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
// Act
|
||||
await this._delegatingStore.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedSession,
|
||||
expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedSession,
|
||||
expectedCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync awaits the inner store's result before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncAwaitsInnerStoreResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var taskCompletionSource = new TaskCompletionSource<AgentSession>();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<AgentSession>(taskCompletionSource.Task));
|
||||
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
taskCompletionSource.SetResult(this._testSession);
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
Assert.Same(this._testSession, await resultTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync awaits the inner store's completion before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedSession = new TestAgentSession();
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask(taskCompletionSource.Task));
|
||||
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
taskCompletionSource.SetResult();
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
await resultTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting the exact type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForExactType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting a base type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForBaseType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting AgentSessionStore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForAgentSessionStoreType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(AgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains to inner store when type is not satisfied by outer store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService(typeof(ConcreteAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through multiple delegation layers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsThoughMultipleDelegationLayers()
|
||||
{
|
||||
// Arrange - create a three-layer chain: outer -> middle -> inner
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
|
||||
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
|
||||
|
||||
// Act - request the innermost store type
|
||||
var result = outerStore.GetService(typeof(ConcreteAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can find a store in the middle of the delegation chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceFindsMiddleStoreInChain()
|
||||
{
|
||||
// Arrange - create a three-layer chain: outer -> middle -> inner
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
|
||||
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
|
||||
|
||||
// Act - request the middle store type
|
||||
var result = outerStore.GetService(typeof(AnotherDelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(middleStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when the requested type is not found in the chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsNullWhenTypeNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService(typeof(string));
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when a service key is provided but not matched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsNullWhenServiceKeyProvided()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService throws ArgumentNullException when serviceType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceThrowsWhenServiceTypeIsNull() =>
|
||||
Assert.Throws<ArgumentNullException>("serviceType", () => this._delegatingStore.GetService(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsItself()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<TestDelegatingAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method chains to inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method returns null when type not found.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsNullWhenTypeNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<string>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Implementation
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of DelegatingAgentSessionStore for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore)
|
||||
{
|
||||
public new AgentSessionStore InnerStore => base.InnerStore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Another delegating store implementation for testing multi-layer chains.
|
||||
/// </summary>
|
||||
private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore);
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
#endregion
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreTests
|
||||
{
|
||||
private const string TestIsolationKey = "test-key";
|
||||
private const string TestConversationId = "test-conversation-id";
|
||||
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public IsolationKeyScopedAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () =>
|
||||
new IsolationKeyScopedAgentSessionStore(null!, provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert - should not throw
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null);
|
||||
Assert.NotNull(store);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncScopesConversationIdWithKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"{TestIsolationKey}::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
|
||||
|
||||
// Act - should not throw
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - conversation ID should be passed through unmodified
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
TestConversationId,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync returns the session from the inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SaveSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act
|
||||
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"{TestIsolationKey}::{TestConversationId}",
|
||||
sessionToSave,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act - should not throw
|
||||
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
|
||||
|
||||
// Assert - conversation ID should be passed through unmodified
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
TestConversationId,
|
||||
sessionToSave,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escaping Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that colons in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesColonsInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithColon = "key:with:colons";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithColon);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - colons should be escaped as \:
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"key\\:with\\:colons::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that backslashes in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesBackslashesInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithBackslash = @"domain\key";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - backslashes should be escaped as \\
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"domain\\\\key::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that both backslashes and colons in the isolation key are escaped correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithBoth = @"domain\key:role";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithBoth);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - backslashes escaped first, then colons
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"domain\\\\key\\:role::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Isolation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that different isolation keys result in different scoped conversation IDs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string Key1 = "key-1";
|
||||
const string Key2 = "key-2";
|
||||
string? capturedConversationId1 = null;
|
||||
string? capturedConversationId2 = null;
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<AIAgent, string, CancellationToken>((_, conversationId, _) =>
|
||||
{
|
||||
if (capturedConversationId1 == null)
|
||||
{
|
||||
capturedConversationId1 = conversationId;
|
||||
}
|
||||
else
|
||||
{
|
||||
capturedConversationId2 = conversationId;
|
||||
}
|
||||
})
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act - Key 1
|
||||
var provider1 = new TestSessionIsolationKeyProvider(Key1);
|
||||
var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1);
|
||||
await store1.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Act - Key 2
|
||||
var provider2 = new TestSessionIsolationKeyProvider(Key2);
|
||||
var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2);
|
||||
await store2.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1);
|
||||
Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2);
|
||||
Assert.NotEqual(capturedConversationId1, capturedConversationId2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<IsolationKeyScopedAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(store, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through to find inner store types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var concreteInnerStore = new ConcreteAgentSessionStore();
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(concreteInnerStore, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
|
||||
/// </summary>
|
||||
public class SessionIsolationKeyProviderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return a non-null isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedKey = "test-key";
|
||||
var provider = new TestSessionIsolationKeyProvider(ExpectedKey);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ExpectedKey, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return null when no key is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that cancellation token is passed through to the provider implementation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestCancellableSessionIsolationKeyProvider();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TaskCanceledException>(
|
||||
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
|
||||
}
|
||||
|
||||
#region Test Implementations
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation that respects cancellation tokens.
|
||||
/// </summary>
|
||||
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
return "key";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -51,7 +51,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
|
||||
// Act
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
@@ -75,7 +75,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
|
||||
// Act
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
@@ -111,8 +111,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
|
||||
|
||||
// Act
|
||||
@@ -131,8 +131,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
|
||||
@@ -156,7 +156,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
|
||||
// Act
|
||||
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 999, Reason = "Done" } } });
|
||||
@@ -173,8 +173,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Research topic" } } });
|
||||
|
||||
// Act
|
||||
@@ -200,8 +200,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
|
||||
|
||||
// Act
|
||||
@@ -220,8 +220,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
|
||||
@@ -244,7 +244,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
|
||||
// Act
|
||||
object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 999 } });
|
||||
@@ -265,9 +265,9 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction getRemainingTodos = GetTool(tools, "TodoList_GetRemaining");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
AIFunction getRemainingTodos = GetTool(tools, "todos_get_remaining");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -295,9 +295,9 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction getAllTodos = GetTool(tools, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
AIFunction getAllTodos = GetTool(tools, "todos_get_all");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -332,12 +332,12 @@ public class TodoProviderTests
|
||||
|
||||
// Act — first invocation adds a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Persisted", Description = null } } });
|
||||
|
||||
// Second invocation should see the same state
|
||||
AIContext result2 = await provider.InvokingAsync(context);
|
||||
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_GetAll");
|
||||
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "todos_get_all");
|
||||
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
|
||||
|
||||
// Assert
|
||||
@@ -364,7 +364,7 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } },
|
||||
@@ -393,8 +393,8 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -556,8 +556,8 @@ public class TodoProviderTests
|
||||
|
||||
// First invocation — add some todos (one with a description to cover that branch)
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Complete");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput>
|
||||
@@ -622,7 +622,7 @@ public class TodoProviderTests
|
||||
|
||||
// First invocation — add a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Task A" } },
|
||||
@@ -687,7 +687,7 @@ public class TodoProviderTests
|
||||
|
||||
// Add a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Original" } },
|
||||
@@ -725,8 +725,8 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
|
||||
|
||||
// Act — launch multiple concurrent adds
|
||||
var tasks = Enumerable.Range(0, 10).Select(i =>
|
||||
@@ -760,9 +760,9 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
|
||||
|
||||
// Add initial items
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
|
||||
+30
-1
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.7.0] - 2026-05-28
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Add `HarnessAgent` and background-agents harness provider ([#6041](https://github.com/microsoft/agent-framework/pull/6041), [#6069](https://github.com/microsoft/agent-framework/pull/6069))
|
||||
- **agent-framework-core**, **agent-framework-a2a**: Add `A2AAgentSession` with referenced task IDs and input-required support ([#5980](https://github.com/microsoft/agent-framework/pull/5980))
|
||||
- **agent-framework-foundry**: Add experimental prompt-agent conversion and deployment APIs ([#5959](https://github.com/microsoft/agent-framework/pull/5959))
|
||||
- **agent-framework-declarative**: Add Foundry Toolbox MCP invocation support and sample ([#5933](https://github.com/microsoft/agent-framework/pull/5933))
|
||||
- **samples**: Add hosting samples overview README ([#5407](https://github.com/microsoft/agent-framework/pull/5407))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-core**: Align TodoProvider tool names with the C# implementation ([#6107](https://github.com/microsoft/agent-framework/pull/6107))
|
||||
- **agent-framework-core**: Align ModeProvider tool names and instructions ([#6071](https://github.com/microsoft/agent-framework/pull/6071))
|
||||
- **agent-framework-chatkit**: Raise the `openai-chatkit` dependency floor to `>=1.6.4` to match the current typed API usage.
|
||||
- **agent-framework-declarative**: [BREAKING] Remove Python-only declarative actions and rename alias kinds to C# canonical names ([#6126](https://github.com/microsoft/agent-framework/pull/6126))
|
||||
- **tests**: Replace deprecated `asyncio.iscoroutinefunction` usage in DevUI cleanup-hook tests ([#4563](https://github.com/microsoft/agent-framework/pull/4563))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Point `@experimental` warnings at user code ([#5996](https://github.com/microsoft/agent-framework/pull/5996))
|
||||
- **agent-framework-declarative**: Fix Foreach body exit wiring ([#6050](https://github.com/microsoft/agent-framework/pull/6050))
|
||||
- **agent-framework-devui**: Fix streaming memory growth regression ([#6038](https://github.com/microsoft/agent-framework/pull/6038))
|
||||
- **agent-framework-foundry**: Pass default headers to Foundry agents ([#6040](https://github.com/microsoft/agent-framework/pull/6040))
|
||||
- **agent-framework-foundry-hosting**: Fix hosted handoff argument serialization ([#5861](https://github.com/microsoft/agent-framework/pull/5861))
|
||||
- **agent-framework-foundry-hosting**: Allow hosted checkpoints to restore `MessageRole` values ([#6049](https://github.com/microsoft/agent-framework/pull/6049))
|
||||
- **agent-framework-openai**: Preserve citation `get_url` metadata ([#6037](https://github.com/microsoft/agent-framework/pull/6037))
|
||||
- **agent-framework-openai**: Guard Chat Completions streaming against null deltas ([#5734](https://github.com/microsoft/agent-framework/pull/5734))
|
||||
- **agent-framework-openai**: Read response headers defensively for stream wrappers without `.headers` ([#6028](https://github.com/microsoft/agent-framework/pull/6028), [#6029](https://github.com/microsoft/agent-framework/pull/6029))
|
||||
- **samples**: Fix sequential workflow sample output handling ([#5976](https://github.com/microsoft/agent-framework/pull/5976))
|
||||
|
||||
## [1.6.0] - 2026-05-21
|
||||
|
||||
### Added
|
||||
@@ -1104,7 +1132,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...HEAD
|
||||
[1.7.0]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...python-1.7.0
|
||||
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
|
||||
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
|
||||
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import importlib.metadata
|
||||
|
||||
from ._a2a_executor import A2AExecutor
|
||||
from ._agent import A2AAgent, A2AContinuationToken
|
||||
from ._agent import A2AAgent, A2AAgentSession, A2AContinuationToken
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -12,6 +12,7 @@ except importlib.metadata.PackageNotFoundError:
|
||||
|
||||
__all__ = [
|
||||
"A2AAgent",
|
||||
"A2AAgentSession",
|
||||
"A2AContinuationToken",
|
||||
"A2AExecutor",
|
||||
"__version__",
|
||||
|
||||
@@ -43,11 +43,89 @@ from agent_framework._types import AgentRunInputs
|
||||
from agent_framework.observability import AgentTelemetryLayer
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
|
||||
__all__ = ["A2AAgent", "A2AContinuationToken"]
|
||||
__all__ = ["A2AAgent", "A2AAgentSession", "A2AContinuationToken"]
|
||||
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
|
||||
class A2AAgentSession(AgentSession):
|
||||
"""Session for A2A-based agents.
|
||||
|
||||
Extends AgentSession with A2A protocol-specific state: context_id for
|
||||
conversation tracking, task_id for the most recent task, and task_state
|
||||
for detecting input-required continuations vs. task refinements.
|
||||
|
||||
Attributes:
|
||||
context_id: The A2A conversation context identifier.
|
||||
task_id: The most recent task ID returned by the remote agent.
|
||||
task_state: The state of the most recent task (e.g., completed, input-required).
|
||||
"""
|
||||
|
||||
_CONTEXT_ID_KEY = "a2a_context_id"
|
||||
_TASK_ID_KEY = "a2a_task_id"
|
||||
_TASK_STATE_KEY = "a2a_task_state"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
task_state: TaskState | None = None,
|
||||
) -> None:
|
||||
"""Initialize the A2A agent session.
|
||||
|
||||
Keyword Args:
|
||||
context_id: Optional A2A context ID for conversation tracking.
|
||||
task_id: Optional task ID from a previous interaction.
|
||||
task_state: Optional state of the most recent task.
|
||||
"""
|
||||
super().__init__(service_session_id=context_id)
|
||||
self.context_id: str | None = context_id
|
||||
self.task_id: str | None = task_id
|
||||
self.task_state: TaskState | None = task_state
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize session to a plain dict for storage/transfer."""
|
||||
data = super().to_dict()
|
||||
if self.context_id is not None:
|
||||
data[self._CONTEXT_ID_KEY] = self.context_id
|
||||
if self.task_id is not None:
|
||||
data[self._TASK_ID_KEY] = self.task_id
|
||||
if self.task_state is not None:
|
||||
data[self._TASK_STATE_KEY] = self.task_state
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> A2AAgentSession:
|
||||
"""Restore session from a previously serialized dict.
|
||||
|
||||
Args:
|
||||
data: Dict from a previous ``to_dict()`` call.
|
||||
|
||||
Returns:
|
||||
Restored A2AAgentSession instance.
|
||||
"""
|
||||
data = dict(data) # defensive copy
|
||||
context_id = data.pop(cls._CONTEXT_ID_KEY, None)
|
||||
task_id = data.pop(cls._TASK_ID_KEY, None)
|
||||
task_state_value = data.pop(cls._TASK_STATE_KEY, None)
|
||||
|
||||
# TaskState is a protobuf enum (int values); store and restore as-is
|
||||
task_state: TaskState | None = task_state_value if task_state_value is not None else None
|
||||
|
||||
# Delegate state deserialization to the base class
|
||||
base_session = AgentSession.from_dict(data)
|
||||
|
||||
session = cls(
|
||||
context_id=context_id or base_session.service_session_id,
|
||||
task_id=task_id,
|
||||
task_state=task_state,
|
||||
)
|
||||
session._session_id = base_session.session_id
|
||||
session.state.update(base_session.state)
|
||||
return session
|
||||
|
||||
|
||||
class A2AContinuationToken(ContinuationToken):
|
||||
"""Continuation token for A2A protocol long-running tasks."""
|
||||
|
||||
@@ -314,10 +392,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
else:
|
||||
if not normalized_messages:
|
||||
raise ValueError("At least one message is required when starting a new task (no continuation_token).")
|
||||
a2a_message = self._prepare_message_for_a2a(
|
||||
normalized_messages[-1],
|
||||
context_id=session.service_session_id if session else None,
|
||||
)
|
||||
a2a_message = self._prepare_message_for_a2a(normalized_messages[-1], session=session)
|
||||
request = SendMessageRequest(message=a2a_message)
|
||||
if background and not stream:
|
||||
# return_immediately only applies to non-streaming (message/send)
|
||||
@@ -392,6 +467,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
|
||||
all_updates: list[AgentResponseUpdate] = []
|
||||
streamed_artifact_ids_by_task: dict[str, set[str]] = {}
|
||||
last_task_id: str | None = None
|
||||
last_context_id: str | None = None
|
||||
last_task_state: TaskState | None = None
|
||||
# In non-streaming mode, accumulate intermediate status content so it
|
||||
# can be surfaced when the terminal event arrives (mirroring v0.3.x
|
||||
# behavior where the full Task history was available at completion).
|
||||
@@ -401,6 +479,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
if payload_type == "message":
|
||||
# Process A2A Message
|
||||
msg = item.message
|
||||
if msg.context_id:
|
||||
last_context_id = msg.context_id
|
||||
contents = self._parse_contents_from_a2a(msg.parts)
|
||||
metadata = MessageToDict(msg.metadata) if msg.metadata else None
|
||||
update = AgentResponseUpdate(
|
||||
@@ -414,6 +494,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
yield update
|
||||
elif payload_type == "task":
|
||||
task = item.task
|
||||
last_task_id = task.id
|
||||
if task.context_id:
|
||||
last_context_id = task.context_id
|
||||
last_task_state = task.status.state
|
||||
updates = self._updates_from_task(
|
||||
task,
|
||||
background=background,
|
||||
@@ -435,20 +519,25 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
yield update
|
||||
elif payload_type == "status_update":
|
||||
status_event = item.status_update
|
||||
last_task_id = status_event.task_id
|
||||
if status_event.context_id:
|
||||
last_context_id = status_event.context_id
|
||||
last_task_state = status_event.status.state
|
||||
updates = self._updates_from_task_update_event(status_event)
|
||||
is_terminal = status_event.status.state in TERMINAL_TASK_STATES
|
||||
is_input_required = status_event.status.state == TaskState.TASK_STATE_INPUT_REQUIRED
|
||||
if emit_intermediate:
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif is_terminal:
|
||||
elif is_terminal or is_input_required:
|
||||
if updates:
|
||||
# Terminal event with content — discard accumulated intermediates
|
||||
# Terminal/input-required event with content — discard accumulated intermediates
|
||||
pending_updates_by_task.pop(status_event.task_id, None)
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
elif is_terminal:
|
||||
# Terminal event with NO content — flush accumulated updates
|
||||
pending = pending_updates_by_task.pop(status_event.task_id, [])
|
||||
for update in pending:
|
||||
@@ -460,6 +549,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
pending_updates_by_task.setdefault(status_event.task_id, []).extend(updates)
|
||||
elif payload_type == "artifact_update":
|
||||
artifact_event = item.artifact_update
|
||||
last_task_id = artifact_event.task_id
|
||||
if artifact_event.context_id:
|
||||
last_context_id = artifact_event.context_id
|
||||
updates = self._updates_from_task_update_event(artifact_event)
|
||||
# Always yield artifact updates — they carry actual response
|
||||
# content (files, data). Track IDs so that a subsequent
|
||||
@@ -478,6 +570,22 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
if all_updates:
|
||||
session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment]
|
||||
|
||||
# Persist A2A protocol state on the session for follow-up message linking.
|
||||
if isinstance(session, A2AAgentSession) and (last_task_id or last_context_id):
|
||||
# Validate context_id consistency
|
||||
if session.context_id is not None and last_context_id and session.context_id != last_context_id:
|
||||
raise RuntimeError(
|
||||
f"The context_id returned from the A2A agent ('{last_context_id}') "
|
||||
f"differs from the session's context_id ('{session.context_id}')."
|
||||
)
|
||||
# Assign server-generated context_id if not already set
|
||||
if session.context_id is None and last_context_id:
|
||||
session.context_id = last_context_id
|
||||
session.service_session_id = last_context_id
|
||||
if last_task_id:
|
||||
session.task_id = last_task_id
|
||||
session.task_state = last_task_state
|
||||
|
||||
await self._run_after_providers(session=session, context=session_context)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -601,6 +709,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
if not update_event.status.HasField("message") or not update_event.status.message.parts:
|
||||
return []
|
||||
|
||||
state = update_event.status.state
|
||||
if state not in TERMINAL_TASK_STATES and state != TaskState.TASK_STATE_INPUT_REQUIRED:
|
||||
return []
|
||||
|
||||
message = update_event.status.message
|
||||
contents = self._parse_contents_from_a2a(message.parts)
|
||||
if not contents:
|
||||
@@ -609,6 +721,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
msg_meta = MessageToDict(message.metadata) if message.metadata else {}
|
||||
event_meta = MessageToDict(update_event.metadata) if update_event.metadata else {}
|
||||
merged_metadata = {**msg_meta, **event_meta} or None
|
||||
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=contents,
|
||||
@@ -647,7 +760,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
return AgentResponse.from_updates(updates)
|
||||
return AgentResponse(messages=[], response_id=task.id, raw_representation=task)
|
||||
|
||||
def _prepare_message_for_a2a(self, message: Message, *, context_id: str | None = None) -> A2AMessage:
|
||||
def _prepare_message_for_a2a(self, message: Message, *, session: AgentSession | None = None) -> A2AMessage:
|
||||
"""Prepare a Message for the A2A protocol.
|
||||
|
||||
Transforms Agent Framework Message objects into A2A protocol Messages by:
|
||||
@@ -656,14 +769,33 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
- Converting file references (URI/data/hosted_file) to FilePart objects
|
||||
- Preserving metadata and additional properties from the original message
|
||||
- Setting the role to 'user' as framework messages are treated as user input
|
||||
- Linking follow-up messages to previous tasks via reference_task_ids or task_id
|
||||
|
||||
When the session is an ``A2AAgentSession``, the method reads context_id,
|
||||
task_id, and task_state directly. If the task is in INPUT_REQUIRED state,
|
||||
the outbound message's ``task_id`` is set (continuing the same task);
|
||||
otherwise ``reference_task_ids`` is used for task refinement linking.
|
||||
|
||||
Args:
|
||||
message: The framework Message to convert.
|
||||
context_id: Optional fallback context identifier (e.g. derived from
|
||||
``AgentSession.service_session_id``). When the *message* already
|
||||
carries a ``context_id`` in its ``additional_properties`` that
|
||||
value takes precedence; otherwise this fallback is used.
|
||||
|
||||
Keyword Args:
|
||||
session: Optional session to read A2A state from. If an
|
||||
``A2AAgentSession``, context_id/task_id/task_state are used for
|
||||
linking. A plain ``AgentSession`` provides service_session_id as
|
||||
a fallback context_id.
|
||||
"""
|
||||
# Extract A2A state from the session
|
||||
context_id: str | None = None
|
||||
previous_task_id: str | None = None
|
||||
task_state: TaskState | None = None
|
||||
if isinstance(session, A2AAgentSession):
|
||||
context_id = session.context_id
|
||||
previous_task_id = session.task_id
|
||||
task_state = session.task_state
|
||||
elif session is not None:
|
||||
context_id = session.service_session_id
|
||||
|
||||
parts: list[A2APart] = []
|
||||
if not message.contents:
|
||||
raise ValueError("Message.contents is empty; cannot convert to A2AMessage.")
|
||||
@@ -722,14 +854,24 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
|
||||
a2a_metadata = message.additional_properties.get("a2a_metadata")
|
||||
|
||||
return A2AMessage(
|
||||
a2a_message = A2AMessage(
|
||||
role=A2ARole.ROLE_USER,
|
||||
parts=parts,
|
||||
message_id=message.message_id or uuid.uuid4().hex,
|
||||
context_id=message.additional_properties.get("context_id") or context_id,
|
||||
context_id=context_id,
|
||||
metadata=a2a_metadata or {},
|
||||
)
|
||||
|
||||
if previous_task_id:
|
||||
if task_state == TaskState.TASK_STATE_INPUT_REQUIRED:
|
||||
# Task is waiting for user input — set task_id to continue the same task
|
||||
a2a_message.task_id = previous_task_id
|
||||
else:
|
||||
# Link as a follow-up (task refinement)
|
||||
a2a_message.reference_task_ids.append(previous_task_id)
|
||||
|
||||
return a2a_message
|
||||
|
||||
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Content]:
|
||||
"""Parse A2A Parts into Agent Framework Content.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from agent_framework import (
|
||||
from agent_framework.a2a import A2AAgent
|
||||
from pytest import fixture, mark, raises
|
||||
|
||||
from agent_framework_a2a import A2AContinuationToken
|
||||
from agent_framework_a2a import A2AAgentSession, A2AContinuationToken
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
|
||||
@@ -482,24 +482,25 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_forwards_context_id() -> None:
|
||||
"""Test conversion of Message preserves context_id without duplicating it in metadata."""
|
||||
"""Test conversion of Message uses context_id from A2AAgentSession."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
|
||||
message = Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Continue the task")],
|
||||
additional_properties={"context_id": "ctx-123", "a2a_metadata": {"trace_id": "trace-456"}},
|
||||
additional_properties={"a2a_metadata": {"trace_id": "trace-456"}},
|
||||
)
|
||||
|
||||
result = agent._prepare_message_for_a2a(message)
|
||||
session = A2AAgentSession(context_id="ctx-123")
|
||||
result = agent._prepare_message_for_a2a(message, session=session)
|
||||
|
||||
assert result.context_id == "ctx-123"
|
||||
assert result.metadata == {"trace_id": "trace-456"}
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_uses_fallback_context_id() -> None:
|
||||
"""Test that context_id kwarg is used when message has no context_id property."""
|
||||
"""Test that service_session_id from a plain session is used when message has no context_id property."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
|
||||
@@ -508,25 +509,26 @@ def test_prepare_message_for_a2a_uses_fallback_context_id() -> None:
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
)
|
||||
|
||||
result = agent._prepare_message_for_a2a(message, context_id="session-ctx-1")
|
||||
session = AgentSession(service_session_id="session-ctx-1")
|
||||
result = agent._prepare_message_for_a2a(message, session=session)
|
||||
|
||||
assert result.context_id == "session-ctx-1"
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_message_context_id_takes_precedence() -> None:
|
||||
"""Test that message.additional_properties context_id wins over the fallback."""
|
||||
def test_prepare_message_for_a2a_a2a_session_context_id_takes_precedence() -> None:
|
||||
"""Test that A2AAgentSession.context_id is used over plain session service_session_id."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
|
||||
message = Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
additional_properties={"context_id": "explicit-ctx"},
|
||||
)
|
||||
|
||||
result = agent._prepare_message_for_a2a(message, context_id="session-ctx-1")
|
||||
session = A2AAgentSession(context_id="a2a-ctx")
|
||||
result = agent._prepare_message_for_a2a(message, session=session)
|
||||
|
||||
assert result.context_id == "explicit-ctx"
|
||||
assert result.context_id == "a2a-ctx"
|
||||
|
||||
|
||||
def test_parse_contents_from_a2a_with_data_part() -> None:
|
||||
@@ -758,9 +760,7 @@ async def test_background_sets_return_immediately_on_request(
|
||||
assert mock_a2a_client.last_request.configuration.return_immediately is True
|
||||
|
||||
|
||||
async def test_foreground_does_not_set_return_immediately(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
async def test_foreground_does_not_set_return_immediately(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that background=False (default) does not set configuration on SendMessageRequest."""
|
||||
mock_a2a_client.add_task_response("task-fg2", [{"id": "art-1", "content": "Done"}])
|
||||
|
||||
@@ -963,21 +963,16 @@ async def test_run_passes_session_service_session_id_as_context_id(mock_a2a_clie
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_run_message_context_id_takes_precedence_over_session(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that an explicit context_id on the message wins over session.service_session_id."""
|
||||
async def test_run_a2a_session_context_id_used_over_service_session_id(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that A2AAgentSession.context_id is used for outbound messages."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_message_response("msg-ctx2", "reply")
|
||||
|
||||
session = AgentSession(service_session_id="svc-session-42")
|
||||
message = Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
additional_properties={"context_id": "explicit-ctx"},
|
||||
)
|
||||
await agent.run(messages=[message], session=session)
|
||||
session = A2AAgentSession(context_id="a2a-ctx-99")
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert mock_a2a_client.last_message is not None
|
||||
assert mock_a2a_client.last_message.context_id == "explicit-ctx"
|
||||
assert mock_a2a_client.last_message.context_id == "a2a-ctx-99"
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -1332,16 +1327,17 @@ async def test_streaming_artifact_update_event_yields_content(
|
||||
async def test_streaming_status_update_event_yields_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that streaming status update events surface message content directly from the update event."""
|
||||
"""Test that streaming status update events surface content for terminal/input-required states only."""
|
||||
# COMPLETED state should yield content (terminal)
|
||||
update_event = TaskStatusUpdateEvent(
|
||||
task_id="task-status",
|
||||
context_id="ctx-status",
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
state=TaskState.TASK_STATE_COMPLETED,
|
||||
message=A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Still working")],
|
||||
parts=[Part(text="Done")],
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1352,11 +1348,60 @@ async def test_streaming_status_update_event_yields_content(
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 1
|
||||
assert updates[0].text == "Still working"
|
||||
assert updates[0].text == "Done"
|
||||
assert updates[0].role == "assistant"
|
||||
assert updates[0].raw_representation == update_event
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_streaming_input_required_emits_content(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that input-required status updates emit content (gated states that pass through)."""
|
||||
update_event = TaskStatusUpdateEvent(
|
||||
task_id="task-status",
|
||||
context_id="ctx-status",
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
message=A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="What is your name?")],
|
||||
),
|
||||
),
|
||||
)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=update_event))
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 1
|
||||
assert updates[0].text == "What is your name?"
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_streaming_working_status_gates_content(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that intermediate WORKING status updates do NOT emit content (gated like .NET)."""
|
||||
update_event = TaskStatusUpdateEvent(
|
||||
task_id="task-status",
|
||||
context_id="ctx-status",
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
message=A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Processing...")],
|
||||
),
|
||||
),
|
||||
)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=update_event))
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 0
|
||||
|
||||
|
||||
async def test_streaming_artifact_update_event_does_not_duplicate_terminal_task_artifacts(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
@@ -1576,28 +1621,17 @@ async def test_task_status_update_event_metadata_merged(a2a_agent: A2AAgent, moc
|
||||
task_id="task-se",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
state=TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
message=A2AMessage(
|
||||
message_id="m1",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="working...")],
|
||||
parts=[Part(text="need input")],
|
||||
metadata={"msg_key": "msg_val"},
|
||||
),
|
||||
),
|
||||
metadata={"event_key": "event_val"},
|
||||
)
|
||||
terminal_task = Task(
|
||||
id="task-se",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
artifacts=[
|
||||
Artifact(artifact_id="a1", parts=[Part(text="done")]),
|
||||
],
|
||||
)
|
||||
mock_a2a_client.responses.extend([
|
||||
StreamResponse(status_update=status_event),
|
||||
StreamResponse(task=terminal_task),
|
||||
])
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=status_event))
|
||||
|
||||
stream = a2a_agent.run("hello", stream=True)
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
@@ -1681,11 +1715,9 @@ async def test_non_streaming_terminal_status_update_surfaces_content(
|
||||
assert response.messages[0].text == "Done! Here is your answer."
|
||||
|
||||
|
||||
async def test_non_streaming_accumulates_working_content_for_empty_terminal(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Non-streaming run() accumulates WORKING content and flushes on empty terminal event."""
|
||||
# Intermediate WORKING event with content
|
||||
async def test_non_streaming_working_content_gated(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Non-streaming: WORKING status content is gated and not surfaced to callers."""
|
||||
# Intermediate WORKING event with content — should be gated
|
||||
working_msg = A2AMessage(
|
||||
message_id="msg-working",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
@@ -1702,9 +1734,8 @@ async def test_non_streaming_accumulates_working_content_for_empty_terminal(
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
# The accumulated WORKING content is flushed when terminal arrives empty
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Here is your answer from working state."
|
||||
# WORKING content is gated — nothing to accumulate or flush
|
||||
assert len(response.messages) == 0
|
||||
|
||||
|
||||
async def test_non_streaming_intermediate_discarded_when_terminal_has_content(
|
||||
@@ -1761,3 +1792,268 @@ async def test_non_streaming_artifact_update_surfaces_content(
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Reference Task IDs Tests
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_first_message_has_no_reference_task_ids(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that the first message sent has no reference_task_ids."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_task_response("task-first", [{"content": "Hello back"}])
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert mock_a2a_client.last_message is not None
|
||||
assert list(mock_a2a_client.last_message.reference_task_ids) == []
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_follow_up_message_includes_reference_task_ids(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a follow-up message references the previous task_id."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_task_response("task-abc-123", [{"content": "First reply"}])
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
# Verify task_id was persisted on session
|
||||
assert session.task_id == "task-abc-123"
|
||||
|
||||
# Send a follow-up message
|
||||
mock_a2a_client.add_task_response("task-def-456", [{"content": "Second reply"}])
|
||||
await agent.run("Follow up", session=session)
|
||||
|
||||
assert mock_a2a_client.last_message is not None
|
||||
assert list(mock_a2a_client.last_message.reference_task_ids) == ["task-abc-123"]
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_reference_task_ids_updated_after_each_interaction(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that reference_task_ids always points to the most recent task."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
session = A2AAgentSession()
|
||||
|
||||
# First interaction
|
||||
mock_a2a_client.add_task_response("task-1", [{"content": "Reply 1"}])
|
||||
await agent.run("Message 1", session=session)
|
||||
assert session.task_id == "task-1"
|
||||
|
||||
# Second interaction
|
||||
mock_a2a_client.add_task_response("task-2", [{"content": "Reply 2"}])
|
||||
await agent.run("Message 2", session=session)
|
||||
assert mock_a2a_client.last_message.reference_task_ids == ["task-1"]
|
||||
assert session.task_id == "task-2"
|
||||
|
||||
# Third interaction references the second task
|
||||
mock_a2a_client.add_task_response("task-3", [{"content": "Reply 3"}])
|
||||
await agent.run("Message 3", session=session)
|
||||
assert mock_a2a_client.last_message.reference_task_ids == ["task-2"]
|
||||
assert session.task_id == "task-3"
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_task_id_tracked_from_status_update_events(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that task_id is tracked even when response only contains status update events."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# Simulate a stream that only has status_update events (no full task payload)
|
||||
status_event = TaskStatusUpdateEvent(
|
||||
task_id="task-from-status",
|
||||
context_id="ctx-1",
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_COMPLETED,
|
||||
message=A2AMessage(
|
||||
message_id="msg-status",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Done")],
|
||||
),
|
||||
),
|
||||
)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=status_event))
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert session.task_id == "task-from-status"
|
||||
assert session.task_state == TaskState.TASK_STATE_COMPLETED
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_no_session_does_not_crash_reference_task_ids(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that running without a session (no reference tracking) works fine."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_task_response("task-no-session", [{"content": "Reply"}])
|
||||
|
||||
# Should not raise — no session means no reference_task_ids
|
||||
response = await agent.run("Hello")
|
||||
assert response is not None
|
||||
assert mock_a2a_client.last_message.reference_task_ids == []
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_task_id_not_tracked_from_message_payload(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that task_id is NOT tracked from message payloads (simple interactions without task tracking)."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# Simulate a response that is a message with task_id set (no task/status_update events).
|
||||
# Per A2A spec, a Message response indicates simple interaction — task_id should not be persisted.
|
||||
message_with_task = A2AMessage(
|
||||
message_id="msg-with-task",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Response")],
|
||||
task_id="task-from-message",
|
||||
)
|
||||
mock_a2a_client.responses.append(StreamResponse(message=message_with_task))
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert session.task_id is None
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_context_id_assigned_from_response(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that context_id is assigned from the response when not set on session."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_task_response("task-ctx", [{"content": "Reply"}])
|
||||
|
||||
session = A2AAgentSession()
|
||||
assert session.context_id is None
|
||||
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
# context_id from the task response should be assigned
|
||||
assert session.context_id == "test-context"
|
||||
assert session.service_session_id == "test-context"
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_context_id_tracked_from_message_payload(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that context_id is captured from message-only responses (no task payload)."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# Simulate a response with only a message that has context_id but no task_id
|
||||
message_with_context = A2AMessage(
|
||||
message_id="msg-ctx-only",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Hello!")],
|
||||
context_id="server-ctx-123",
|
||||
)
|
||||
mock_a2a_client.responses.append(StreamResponse(message=message_with_context))
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Hi", session=session)
|
||||
|
||||
# context_id should be captured even without a task_id
|
||||
assert session.context_id == "server-ctx-123"
|
||||
assert session.service_session_id == "server-ctx-123"
|
||||
assert session.task_id is None
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_context_id_mismatch_raises_error(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a context_id mismatch between session and response raises an error."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# Task response has context_id="test-context" (from add_task_response helper)
|
||||
mock_a2a_client.add_task_response("task-mismatch", [{"content": "Reply"}])
|
||||
|
||||
# Session already has a different context_id
|
||||
session = A2AAgentSession(context_id="different-context")
|
||||
|
||||
with raises(RuntimeError, match="differs from the session's context_id"):
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_task_state_tracked_on_session(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that task_state is tracked on A2AAgentSession."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# Add a task that ends in INPUT_REQUIRED
|
||||
mock_a2a_client.add_in_progress_task_response(
|
||||
"task-input",
|
||||
context_id="ctx-input",
|
||||
state=TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
text="What is your name?",
|
||||
)
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Start", session=session)
|
||||
|
||||
assert session.task_id == "task-input"
|
||||
assert session.task_state == TaskState.TASK_STATE_INPUT_REQUIRED
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_plain_agent_session_no_reference_tracking(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a plain AgentSession works but does not get reference_task_ids tracking."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_task_response("task-plain", [{"content": "Reply"}])
|
||||
|
||||
session = AgentSession()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
# Plain session does not get task_id tracking
|
||||
assert "a2a_task_id" not in session.state
|
||||
|
||||
# Follow-up has no reference_task_ids (no tracking on plain session)
|
||||
mock_a2a_client.add_task_response("task-plain-2", [{"content": "Reply 2"}])
|
||||
await agent.run("Follow up", session=session)
|
||||
assert list(mock_a2a_client.last_message.reference_task_ids) == []
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_a2a_agent_session_serialization() -> None:
|
||||
"""Test A2AAgentSession serialization and deserialization."""
|
||||
session = A2AAgentSession(
|
||||
context_id="ctx-456",
|
||||
task_id="task-789",
|
||||
task_state=TaskState.TASK_STATE_COMPLETED,
|
||||
)
|
||||
|
||||
data = session.to_dict()
|
||||
restored = A2AAgentSession.from_dict(data)
|
||||
|
||||
assert restored.session_id == session.session_id
|
||||
assert restored.context_id == "ctx-456"
|
||||
assert restored.task_id == "task-789"
|
||||
assert restored.task_state == TaskState.TASK_STATE_COMPLETED
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_input_required_sets_task_id_instead_of_reference(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that when task_state is INPUT_REQUIRED, follow-up sets task_id (not reference_task_ids)."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# First turn: task ends in INPUT_REQUIRED
|
||||
mock_a2a_client.add_in_progress_task_response(
|
||||
"task-ir",
|
||||
context_id="ctx-ir",
|
||||
state=TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
text="What is your name?",
|
||||
)
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Start", session=session)
|
||||
|
||||
assert session.task_state == TaskState.TASK_STATE_INPUT_REQUIRED
|
||||
assert session.task_id == "task-ir"
|
||||
|
||||
# Second turn: follow-up should set task_id (not reference_task_ids)
|
||||
mock_a2a_client.add_in_progress_task_response(
|
||||
"task-ir-2", context_id="ctx-ir", state=TaskState.TASK_STATE_COMPLETED, text="Thanks!"
|
||||
)
|
||||
await agent.run("My name is Alice", session=session)
|
||||
|
||||
# The outbound message should have task_id set, not reference_task_ids
|
||||
last_msg = mock_a2a_client.last_message
|
||||
assert last_msg.task_id == "task-ir"
|
||||
assert list(last_msg.reference_task_ids) == []
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,8 +22,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"openai-chatkit>=1.6.4,<2.0.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -12,6 +12,8 @@ from collections.abc import Mapping, MutableMapping
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
@@ -32,11 +34,12 @@ DEFAULT_TODO_INSTRUCTIONS = (
|
||||
"When a user changes the topic or changes their mind, ensure that you update the todo list accordingly "
|
||||
"by removing irrelevant items or adding new ones as needed.\n\n"
|
||||
"Use these tools to manage your tasks:\n"
|
||||
"- Use add_todos to break down complex work into trackable items (supports adding one or many at once).\n"
|
||||
"- Use complete_todos to mark items as done when finished (supports one or many at once).\n"
|
||||
"- Use get_remaining_todos to check what work is still pending.\n"
|
||||
"- Use get_all_todos to review the full list including completed items.\n"
|
||||
"- Use remove_todos to remove items that are no longer needed (supports one or many at once)."
|
||||
"- Use todos_add to break down complex work into trackable items (supports adding one or many at once).\n"
|
||||
"- Use todos_complete to mark items as done when finished (supports one or many at once). "
|
||||
"Include a reason describing how the items were completed.\n"
|
||||
"- Use todos_get_remaining to check what work is still pending.\n"
|
||||
"- Use todos_get_all to review the full list including completed items.\n"
|
||||
"- Use todos_remove to remove items that are no longer needed (supports one or many at once)."
|
||||
)
|
||||
|
||||
|
||||
@@ -48,7 +51,6 @@ class TodoItem(SerializationMixin):
|
||||
title: str
|
||||
description: str | None
|
||||
is_complete: bool
|
||||
__slots__ = ("description", "id", "is_complete", "title")
|
||||
|
||||
def __init__(self, id: int, title: str, description: str | None = None, is_complete: bool = False) -> None:
|
||||
"""Initialize one todo item."""
|
||||
@@ -106,7 +108,6 @@ class TodoInput(SerializationMixin):
|
||||
|
||||
title: str
|
||||
description: str | None
|
||||
__slots__ = ("description", "title")
|
||||
|
||||
def __init__(self, title: str, description: str | None = None) -> None:
|
||||
"""Initialize one todo input."""
|
||||
@@ -137,6 +138,56 @@ class TodoInput(SerializationMixin):
|
||||
return cls(title=title, description=description)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoCompleteInput(SerializationMixin):
|
||||
"""Describe one todo item to mark as complete."""
|
||||
|
||||
id: int
|
||||
reason: str
|
||||
|
||||
def __init__(self, id: int, reason: str) -> None:
|
||||
"""Initialize one todo complete input."""
|
||||
if not isinstance(id, int):
|
||||
raise ValueError("Todo complete input id must be an integer.")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
raise ValueError("Todo complete input reason must be a non-empty string.")
|
||||
self.id = id
|
||||
self.reason = reason.strip()
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize the todo complete input."""
|
||||
del exclude, exclude_none
|
||||
return {"id": self.id, "reason": self.reason}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, raw_item: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None
|
||||
) -> TodoCompleteInput:
|
||||
"""Parse one todo complete input from tool arguments."""
|
||||
del dependencies
|
||||
item_id = raw_item.get("id")
|
||||
reason = raw_item.get("reason")
|
||||
if not isinstance(item_id, int):
|
||||
raise ValueError("Todo complete input id must be an integer.")
|
||||
if not isinstance(reason, str):
|
||||
raise ValueError("Todo complete input reason must be a string.")
|
||||
return cls(id=item_id, reason=reason)
|
||||
|
||||
|
||||
class _TodoAddItemSchema(TypedDict):
|
||||
"""Schema for a single todo item in the todos_add tool."""
|
||||
|
||||
title: str
|
||||
description: NotRequired[str]
|
||||
|
||||
|
||||
class _TodoCompleteItemSchema(TypedDict):
|
||||
"""Schema for a single item in the todos_complete tool."""
|
||||
|
||||
id: int
|
||||
reason: str
|
||||
|
||||
|
||||
def _parse_todo_items(items_payload: list[Any], *, source_description: str) -> list[TodoItem]:
|
||||
"""Parse persisted todo item payloads with clear corruption errors."""
|
||||
items: list[TodoItem] = []
|
||||
@@ -158,6 +209,15 @@ def _coerce_todo_input(todo: TodoInput | dict[str, Any] | Any) -> TodoInput:
|
||||
raise ValueError("Todo input must be a TodoInput instance or JSON object.")
|
||||
|
||||
|
||||
def _coerce_todo_complete_input(item: TodoCompleteInput | dict[str, Any] | Any) -> TodoCompleteInput:
|
||||
"""Normalize tool-provided complete input into a TodoCompleteInput model."""
|
||||
if isinstance(item, TodoCompleteInput):
|
||||
return item
|
||||
if isinstance(item, MutableMapping):
|
||||
return TodoCompleteInput.from_dict(cast(MutableMapping[str, Any], item))
|
||||
raise ValueError("Todo complete input must be a TodoCompleteInput instance or JSON object.")
|
||||
|
||||
|
||||
def _safe_next_id(items: list[TodoItem], next_id: int) -> int:
|
||||
"""Clamp ``next_id`` so it cannot collide with any persisted item id."""
|
||||
return max(next_id, max((item.id for item in items), default=0) + 1)
|
||||
@@ -393,11 +453,11 @@ class TodoProvider(ContextProvider):
|
||||
can provide ``TodoFileStore`` or another store implementation for file-backed or custom persistence.
|
||||
|
||||
This provider exposes the following tools to the agent:
|
||||
- ``add_todos``: Add one or more todo items, each with a title and optional description.
|
||||
- ``complete_todos``: Mark one or more todo items as complete by their IDs.
|
||||
- ``remove_todos``: Remove one or more todo items by their IDs.
|
||||
- ``get_remaining_todos``: Retrieve only incomplete todo items.
|
||||
- ``get_all_todos``: Retrieve all todo items, complete and incomplete.
|
||||
- ``todos_add``: Add one or more todo items, each with a title and optional description.
|
||||
- ``todos_complete``: Mark one or more todo items as complete by their IDs and reasons.
|
||||
- ``todos_remove``: Remove one or more todo items by their IDs.
|
||||
- ``todos_get_remaining``: Retrieve only incomplete todo items.
|
||||
- ``todos_get_all``: Retrieve all todo items, complete and incomplete.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -442,8 +502,8 @@ class TodoProvider(ContextProvider):
|
||||
"""Inject todo tools and instructions before the model runs."""
|
||||
del agent, state
|
||||
|
||||
@tool(name="add_todos", approval_mode="never_require")
|
||||
async def add_todos(todos: list[dict[str, Any]]) -> str:
|
||||
@tool(name="todos_add", approval_mode="never_require")
|
||||
async def todos_add(todos: list[_TodoAddItemSchema]) -> str:
|
||||
"""Add one or more todo items for the current session."""
|
||||
if not todos:
|
||||
raise ValueError("todos must contain at least one item.")
|
||||
@@ -465,18 +525,24 @@ class TodoProvider(ContextProvider):
|
||||
await self.store.save_state(session, existing_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in created_items])
|
||||
|
||||
@tool(name="complete_todos", approval_mode="never_require")
|
||||
async def complete_todos(ids: list[int]) -> str:
|
||||
"""Mark one or more todo items as complete by ID."""
|
||||
if not ids:
|
||||
raise ValueError("ids must contain at least one todo ID.")
|
||||
@tool(name="todos_complete", approval_mode="never_require")
|
||||
async def todos_complete(items: list[_TodoCompleteItemSchema]) -> str:
|
||||
"""Mark one or more todo items as complete.
|
||||
|
||||
Each entry has an id (int) and a reason (string) describing how/why the item was completed.
|
||||
"""
|
||||
if not items:
|
||||
raise ValueError("items must contain at least one entry.")
|
||||
|
||||
parsed = [_coerce_todo_complete_input(entry) for entry in items]
|
||||
ids = [entry.id for entry in parsed]
|
||||
|
||||
async with self._mutation_lock(session):
|
||||
items, next_id = await self.store.load_state(session, source_id=self.source_id)
|
||||
existing_items, next_id = await self.store.load_state(session, source_id=self.source_id)
|
||||
id_set = set(ids)
|
||||
completed_count = 0
|
||||
updated_items: list[TodoItem] = []
|
||||
for item in items:
|
||||
for item in existing_items:
|
||||
if not item.is_complete and item.id in id_set:
|
||||
updated_items.append(
|
||||
TodoItem(
|
||||
@@ -494,8 +560,8 @@ class TodoProvider(ContextProvider):
|
||||
await self.store.save_state(session, updated_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps({"completed": completed_count})
|
||||
|
||||
@tool(name="remove_todos", approval_mode="never_require")
|
||||
async def remove_todos(ids: list[int]) -> str:
|
||||
@tool(name="todos_remove", approval_mode="never_require")
|
||||
async def todos_remove(ids: list[int]) -> str:
|
||||
"""Remove one or more todo items by ID."""
|
||||
if not ids:
|
||||
raise ValueError("ids must contain at least one todo ID.")
|
||||
@@ -508,16 +574,16 @@ class TodoProvider(ContextProvider):
|
||||
await self.store.save_state(session, remaining_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps({"removed": removed_count})
|
||||
|
||||
@tool(name="get_remaining_todos", approval_mode="never_require")
|
||||
async def get_remaining_todos() -> str:
|
||||
@tool(name="todos_get_remaining", approval_mode="never_require")
|
||||
async def todos_get_remaining() -> str:
|
||||
"""Retrieve only incomplete todo items for the current session."""
|
||||
items = [
|
||||
item for item in await self.store.load_items(session, source_id=self.source_id) if not item.is_complete
|
||||
]
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in items])
|
||||
|
||||
@tool(name="get_all_todos", approval_mode="never_require")
|
||||
async def get_all_todos() -> str:
|
||||
@tool(name="todos_get_all", approval_mode="never_require")
|
||||
async def todos_get_all() -> str:
|
||||
"""Retrieve all todo items for the current session."""
|
||||
items = await self.store.load_items(session, source_id=self.source_id)
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in items])
|
||||
@@ -525,7 +591,7 @@ class TodoProvider(ContextProvider):
|
||||
context.extend_instructions(self.source_id, [self.instructions])
|
||||
context.extend_tools(
|
||||
self.source_id,
|
||||
[add_todos, complete_todos, remove_todos, get_remaining_todos, get_all_todos],
|
||||
[todos_add, todos_complete, todos_remove, todos_get_remaining, todos_get_all],
|
||||
)
|
||||
current_items = await self.store.load_items(session, source_id=self.source_id)
|
||||
context.extend_messages(
|
||||
|
||||
@@ -507,38 +507,45 @@ class Skill(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def content(self) -> str:
|
||||
"""The full skill content.
|
||||
async def get_content(self) -> str:
|
||||
"""Get the full skill content.
|
||||
|
||||
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).
|
||||
|
||||
Returns:
|
||||
The full skill content string.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def resources(self) -> list[SkillResource]:
|
||||
"""Resources associated with this skill.
|
||||
async def get_resource(self, name: str) -> SkillResource | None:
|
||||
"""Get a resource owned by this skill by name.
|
||||
|
||||
The default implementation returns an empty list.
|
||||
Override this property in derived classes to provide skill-specific
|
||||
resources.
|
||||
Args:
|
||||
name: The resource name (e.g. an identifier or a relative path
|
||||
referenced inside the skill content).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillResource`, or ``None`` when no resource with the
|
||||
given name exists.
|
||||
"""
|
||||
return []
|
||||
return None
|
||||
|
||||
@property
|
||||
def scripts(self) -> list[SkillScript]:
|
||||
"""Scripts associated with this skill.
|
||||
async def get_script(self, name: str) -> SkillScript | None:
|
||||
"""Get a script owned by this skill by name.
|
||||
|
||||
The default implementation returns an empty list.
|
||||
Override this property in derived classes to provide skill-specific
|
||||
scripts.
|
||||
Args:
|
||||
name: The script name.
|
||||
|
||||
Returns:
|
||||
The :class:`SkillScript`, or ``None`` when no script with the
|
||||
given name exists.
|
||||
"""
|
||||
return []
|
||||
return None
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.SKILLS)
|
||||
@@ -767,12 +774,14 @@ class InlineSkill(Skill):
|
||||
"""The L1 discovery metadata for this skill."""
|
||||
return self._frontmatter
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
async def get_content(self) -> str:
|
||||
"""Synthesized XML content with name, description, instructions, resources, and scripts.
|
||||
|
||||
The result is cached after the first access. Adding resources or
|
||||
scripts after the first access will not be reflected.
|
||||
|
||||
Returns:
|
||||
The synthesized XML content string.
|
||||
"""
|
||||
if self._cached_content is not None:
|
||||
return self._cached_content
|
||||
@@ -786,15 +795,31 @@ class InlineSkill(Skill):
|
||||
)
|
||||
return self._cached_content
|
||||
|
||||
@property
|
||||
def resources(self) -> list[SkillResource]:
|
||||
"""Mutable list of :class:`SkillResource` instances."""
|
||||
return self._resources
|
||||
async def get_resource(self, name: str) -> SkillResource | None:
|
||||
"""Get a resource by name.
|
||||
|
||||
@property
|
||||
def scripts(self) -> list[SkillScript]:
|
||||
"""Mutable list of :class:`SkillScript` instances."""
|
||||
return self._scripts
|
||||
Args:
|
||||
name: The resource name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillResource`, or ``None`` when no resource with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((r for r in self._resources if r.name.lower() == name_lower), None)
|
||||
|
||||
async def get_script(self, name: str) -> SkillScript | None:
|
||||
"""Get a script by name.
|
||||
|
||||
Args:
|
||||
name: The script name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillScript`, or ``None`` when no script with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((s for s in self._scripts if s.name.lower() == name_lower), None)
|
||||
|
||||
def resource(
|
||||
self,
|
||||
@@ -1318,11 +1343,13 @@ class ClassSkill(Skill, ABC):
|
||||
self._cached_scripts = scripts
|
||||
return list(self._cached_scripts)
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
async def get_content(self) -> str:
|
||||
"""Synthesized XML content containing name, description, instructions, resources, and scripts.
|
||||
|
||||
The result is cached after the first access.
|
||||
|
||||
Returns:
|
||||
The synthesized XML content string.
|
||||
"""
|
||||
if self._cached_content is not None:
|
||||
return self._cached_content
|
||||
@@ -1336,6 +1363,32 @@ class ClassSkill(Skill, ABC):
|
||||
)
|
||||
return self._cached_content
|
||||
|
||||
async def get_resource(self, name: str) -> SkillResource | None:
|
||||
"""Get a resource by name from the :attr:`resources` list.
|
||||
|
||||
Args:
|
||||
name: The resource name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillResource`, or ``None`` when no resource with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((r for r in self.resources if r.name.lower() == name_lower), None)
|
||||
|
||||
async def get_script(self, name: str) -> SkillScript | None:
|
||||
"""Get a script by name from the :attr:`scripts` list.
|
||||
|
||||
Args:
|
||||
name: The script name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillScript`, or ``None`` when no script with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((s for s in self.scripts if s.name.lower() == name_lower), None)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.SKILLS)
|
||||
class FileSkill(Skill):
|
||||
@@ -1378,8 +1431,7 @@ class FileSkill(Skill):
|
||||
"""The L1 discovery metadata for this skill."""
|
||||
return self._frontmatter
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
async def get_content(self) -> str:
|
||||
"""The skill content with appended scripts block.
|
||||
|
||||
When scripts are present, a ``<scripts>`` XML block is appended
|
||||
@@ -1388,6 +1440,9 @@ class FileSkill(Skill):
|
||||
|
||||
The result is cached after the first access. Adding scripts
|
||||
after the first access will not be reflected.
|
||||
|
||||
Returns:
|
||||
The skill content string.
|
||||
"""
|
||||
if self._cached_content is not None:
|
||||
return self._cached_content
|
||||
@@ -1398,15 +1453,31 @@ class FileSkill(Skill):
|
||||
self._cached_content = f"{self._content}\n\n<scripts>\n{script_lines}\n</scripts>"
|
||||
return self._cached_content
|
||||
|
||||
@property
|
||||
def resources(self) -> list[SkillResource]:
|
||||
"""Resources discovered for this skill."""
|
||||
return self._resources
|
||||
async def get_resource(self, name: str) -> SkillResource | None:
|
||||
"""Get a resource by name.
|
||||
|
||||
@property
|
||||
def scripts(self) -> list[SkillScript]:
|
||||
"""Scripts discovered for this skill."""
|
||||
return self._scripts
|
||||
Args:
|
||||
name: The resource name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillResource`, or ``None`` when no resource with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((r for r in self._resources if r.name.lower() == name_lower), None)
|
||||
|
||||
async def get_script(self, name: str) -> SkillScript | None:
|
||||
"""Get a script by name.
|
||||
|
||||
Args:
|
||||
name: The script name to look up (case-insensitive).
|
||||
|
||||
Returns:
|
||||
The :class:`SkillScript`, or ``None`` when no script with the
|
||||
given name exists.
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
return next((s for s in self._scripts if s.name.lower() == name_lower), None)
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -1734,13 +1805,13 @@ class SkillsProvider(ContextProvider):
|
||||
Keyword Args:
|
||||
instruction_template: Custom system-prompt template for
|
||||
advertising skills. Must contain a ``{skills}`` placeholder for the
|
||||
generated skills list. If the provider includes file-based script
|
||||
execution instructions, the template must also contain
|
||||
``{runner_instructions}``. If the provider includes resource-reading
|
||||
instructions, the template must also contain
|
||||
``{resource_instructions}``. Omitting any placeholder required by
|
||||
the resolved skills configuration can raise :class:`ValueError` at
|
||||
runtime. Uses a built-in template when ``None``.
|
||||
generated skills list. May optionally contain
|
||||
``{runner_instructions}`` and/or ``{resource_instructions}``
|
||||
placeholders; when present, they are filled with built-in
|
||||
guidance for script execution and resource reading respectively.
|
||||
When omitted, those instructions are simply not included in the
|
||||
rendered prompt (the corresponding tools are still registered).
|
||||
Uses a built-in template when ``None``.
|
||||
require_script_approval: When ``True``, skill script execution
|
||||
requires explicit user approval before running. Instead of
|
||||
executing immediately, the agent pauses and returns a
|
||||
@@ -1867,29 +1938,20 @@ class SkillsProvider(ContextProvider):
|
||||
def _create_instructions(
|
||||
prompt_template: str | None,
|
||||
skills: Sequence[Skill],
|
||||
include_script_runner_instructions: bool = False,
|
||||
include_resource_instructions: bool = False,
|
||||
) -> str | None:
|
||||
"""Create the system-prompt text that advertises available skills.
|
||||
|
||||
Generates an XML list of ``<skill>`` elements (sorted by name) and
|
||||
inserts it into *prompt_template* at the ``{skills}`` placeholder.
|
||||
When *include_script_runner_instructions* is ``True``, executor-provided
|
||||
instructions are inserted at the ``{runner_instructions}`` placeholder.
|
||||
When *include_resource_instructions* is ``True``, resource-reading
|
||||
instructions are inserted at the ``{resource_instructions}`` placeholder.
|
||||
Script-runner instructions are inserted at the
|
||||
``{runner_instructions}`` placeholder and resource-reading
|
||||
instructions at the ``{resource_instructions}`` placeholder.
|
||||
|
||||
Args:
|
||||
prompt_template: Custom template string with ``{skills}`` and
|
||||
optional ``{runner_instructions}`` and ``{resource_instructions}``
|
||||
placeholders, or ``None`` to use the built-in default.
|
||||
skills: Registered skills.
|
||||
include_script_runner_instructions: When ``True``, include
|
||||
script-runner instructions in the generated prompt.
|
||||
Defaults to ``False``.
|
||||
include_resource_instructions: When ``True``, include
|
||||
resource-reading instructions in the generated prompt.
|
||||
Defaults to ``False``.
|
||||
|
||||
Returns:
|
||||
The formatted instruction string, or ``None`` when *skills* is empty.
|
||||
@@ -1898,8 +1960,8 @@ class SkillsProvider(ContextProvider):
|
||||
ValueError: If *prompt_template* is not a valid format string
|
||||
(e.g. missing ``{skills}`` placeholder).
|
||||
"""
|
||||
runner_instructions = SCRIPT_RUNNER_INSTRUCTIONS if include_script_runner_instructions else None
|
||||
resource_instructions = RESOURCE_INSTRUCTIONS if include_resource_instructions else None
|
||||
runner_instructions = SCRIPT_RUNNER_INSTRUCTIONS
|
||||
resource_instructions = RESOURCE_INSTRUCTIONS
|
||||
template = DEFAULT_SKILLS_INSTRUCTION_PROMPT
|
||||
|
||||
if prompt_template is not None:
|
||||
@@ -1921,16 +1983,6 @@ class SkillsProvider(ContextProvider):
|
||||
raise ValueError(
|
||||
"The provided instruction_template must contain a '{skills}' placeholder." # noqa: RUF027
|
||||
)
|
||||
if runner_instructions and "__EXEC_PROBE__" not in result:
|
||||
raise ValueError(
|
||||
"The provided instruction_template must contain an '{runner_instructions}' placeholder " # noqa: RUF027
|
||||
"when a script runner is configured."
|
||||
)
|
||||
if resource_instructions and "__RES_PROBE__" not in result:
|
||||
raise ValueError(
|
||||
"The provided instruction_template must contain a '{resource_instructions}' placeholder " # noqa: RUF027
|
||||
"when skills have resources."
|
||||
)
|
||||
template = prompt_template
|
||||
|
||||
if not skills:
|
||||
@@ -1964,20 +2016,13 @@ class SkillsProvider(ContextProvider):
|
||||
if not skills:
|
||||
return skills, None, []
|
||||
|
||||
has_scripts = any(s.scripts for s in skills)
|
||||
has_resources = any(s.resources for s in skills)
|
||||
|
||||
instructions = self._create_instructions(
|
||||
prompt_template=self._instruction_template,
|
||||
skills=skills,
|
||||
include_script_runner_instructions=has_scripts,
|
||||
include_resource_instructions=has_resources,
|
||||
)
|
||||
|
||||
tools = self._create_tools(
|
||||
skills=skills,
|
||||
include_script_runner_tool=has_scripts,
|
||||
include_resource_tool=has_resources,
|
||||
require_script_approval=self._require_script_approval,
|
||||
)
|
||||
|
||||
@@ -2046,23 +2091,15 @@ class SkillsProvider(ContextProvider):
|
||||
def _create_tools(
|
||||
self,
|
||||
skills: Sequence[Skill],
|
||||
include_script_runner_tool: bool,
|
||||
include_resource_tool: bool,
|
||||
require_script_approval: bool = False,
|
||||
) -> list[FunctionTool]:
|
||||
"""Create the tool definitions for skill interaction.
|
||||
|
||||
Always includes ``load_skill``. Conditionally includes
|
||||
``read_skill_resource`` (when *include_resource_tool* is ``True``)
|
||||
and ``run_skill_script`` (when *include_script_runner_tool* is
|
||||
``True``).
|
||||
Always includes ``load_skill``, ``read_skill_resource``, and
|
||||
``run_skill_script``.
|
||||
|
||||
Args:
|
||||
skills: The skills to bind to tool handlers.
|
||||
include_script_runner_tool: Whether to include the
|
||||
``run_skill_script`` tool in the returned list.
|
||||
include_resource_tool: Whether to include the
|
||||
``read_skill_resource`` tool in the returned list.
|
||||
require_script_approval: When ``True``, the
|
||||
``run_skill_script`` tool pauses for user approval
|
||||
before each invocation.
|
||||
@@ -2070,11 +2107,23 @@ class SkillsProvider(ContextProvider):
|
||||
Returns:
|
||||
A list of :class:`FunctionTool` instances.
|
||||
"""
|
||||
tools = [
|
||||
|
||||
async def _load(skill_name: str) -> str:
|
||||
return await self._load_skill(skills, skill_name)
|
||||
|
||||
async def _read_resource(skill_name: str, resource_name: str, **kwargs: Any) -> Any:
|
||||
return await self._read_skill_resource(skills, skill_name, resource_name, **kwargs)
|
||||
|
||||
async def _run_script(
|
||||
skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs)
|
||||
|
||||
return [
|
||||
FunctionTool(
|
||||
name="load_skill",
|
||||
description="Loads the full instructions for a specific skill.",
|
||||
func=lambda skill_name: self._load_skill(skills, skill_name), # pyright: ignore[reportUnknownArgumentType, reportUnknownLambdaType]
|
||||
func=_load,
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2083,108 +2132,88 @@ class SkillsProvider(ContextProvider):
|
||||
"required": ["skill_name"],
|
||||
},
|
||||
),
|
||||
FunctionTool(
|
||||
name="read_skill_resource",
|
||||
description=(
|
||||
"Reads a resource associated with a skill, such as references, assets, or dynamic data."
|
||||
),
|
||||
func=_read_resource,
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {"type": "string", "description": "The name of the skill."},
|
||||
"resource_name": {
|
||||
"type": "string",
|
||||
"description": "The name of the resource.",
|
||||
},
|
||||
},
|
||||
"required": ["skill_name", "resource_name"],
|
||||
},
|
||||
),
|
||||
FunctionTool(
|
||||
name="run_skill_script",
|
||||
description="Runs a script associated with a skill.",
|
||||
func=_run_script,
|
||||
approval_mode="always_require" if require_script_approval else "never_require",
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {"type": "string", "description": "The name of the skill."},
|
||||
"script_name": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The name of the script to run as listed in the skill, "
|
||||
"preserving any directory prefix exactly as shown. "
|
||||
"Do not add or remove path prefixes."
|
||||
),
|
||||
},
|
||||
"args": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": (
|
||||
"Named arguments as key-value pairs "
|
||||
'(e.g. {"length": 24, "uppercase": true}).'
|
||||
),
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Positional CLI arguments as a string array "
|
||||
'(e.g. ["input.docx", "--output", "result.idx"]).'
|
||||
),
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"description": (
|
||||
"Arguments to pass to the script. "
|
||||
"Use an array of strings for CLI-style positional arguments "
|
||||
'(e.g. ["input.docx", "--output", "result.idx"]), '
|
||||
"or an object for named parameters "
|
||||
'(e.g. {"length": 24, "uppercase": true}). '
|
||||
"How these values are mapped to the underlying script "
|
||||
"is determined by the script implementation or configured runner."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["skill_name", "script_name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
if include_resource_tool:
|
||||
|
||||
async def _read_resource(skill_name: str, resource_name: str, **kwargs: Any) -> Any:
|
||||
return await self._read_skill_resource(skills, skill_name, resource_name, **kwargs)
|
||||
|
||||
tools.append(
|
||||
FunctionTool(
|
||||
name="read_skill_resource",
|
||||
description=(
|
||||
"Reads a resource associated with a skill, such as references, assets, or dynamic data."
|
||||
),
|
||||
func=_read_resource,
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {"type": "string", "description": "The name of the skill."},
|
||||
"resource_name": {
|
||||
"type": "string",
|
||||
"description": "The name of the resource.",
|
||||
},
|
||||
},
|
||||
"required": ["skill_name", "resource_name"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if include_script_runner_tool:
|
||||
|
||||
async def _run_script(
|
||||
skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs)
|
||||
|
||||
tools.append(
|
||||
FunctionTool(
|
||||
name="run_skill_script",
|
||||
description="Runs a script associated with a skill.",
|
||||
func=_run_script,
|
||||
approval_mode="always_require" if require_script_approval else "never_require",
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {"type": "string", "description": "The name of the skill."},
|
||||
"script_name": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The name of the script to run as listed in the skill, "
|
||||
"preserving any directory prefix exactly as shown. "
|
||||
"Do not add or remove path prefixes."
|
||||
),
|
||||
},
|
||||
"args": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": (
|
||||
"Named arguments as key-value pairs "
|
||||
'(e.g. {"length": 24, "uppercase": true}).'
|
||||
),
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Positional CLI arguments as a string array "
|
||||
'(e.g. ["input.docx", "--output", "result.idx"]).'
|
||||
),
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"description": (
|
||||
"Arguments to pass to the script. "
|
||||
"Use an array of strings for CLI-style positional arguments "
|
||||
'(e.g. ["input.docx", "--output", "result.idx"]), '
|
||||
"or an object for named parameters "
|
||||
'(e.g. {"length": 24, "uppercase": true}). '
|
||||
"How these values are mapped to the underlying script "
|
||||
"is determined by the script implementation or configured runner."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["skill_name", "script_name"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
@staticmethod
|
||||
def _find_skill(skills: Sequence[Skill], name: str) -> Skill | None:
|
||||
"""Find a skill by name (case-insensitive linear scan)."""
|
||||
name_lower = name.lower()
|
||||
return next((s for s in skills if s.frontmatter.name.lower() == name_lower), None)
|
||||
|
||||
def _load_skill(self, skills: Sequence[Skill], skill_name: str) -> str:
|
||||
async def _load_skill(self, skills: Sequence[Skill], skill_name: str) -> str:
|
||||
"""Return the full content for the named skill.
|
||||
|
||||
Delegates to the skill's :attr:`~Skill.content` property, which
|
||||
Delegates to the skill's :meth:`~Skill.get_content` method, which
|
||||
handles format differences between file-based and code-defined skills.
|
||||
|
||||
Args:
|
||||
@@ -2204,7 +2233,7 @@ class SkillsProvider(ContextProvider):
|
||||
|
||||
logger.info("Loading skill: %s", skill_name)
|
||||
|
||||
return skill.content
|
||||
return await skill.get_content()
|
||||
|
||||
async def _run_skill_script(
|
||||
self,
|
||||
@@ -2243,7 +2272,7 @@ class SkillsProvider(ContextProvider):
|
||||
if not skill:
|
||||
return f"Error: Skill '{skill_name}' not found."
|
||||
|
||||
script = next((s for s in skill.scripts if s.name.lower() == script_name.lower()), None)
|
||||
script = await skill.get_script(script_name)
|
||||
if not script:
|
||||
return f"Error: Script '{script_name}' not found in skill '{skill_name}'."
|
||||
|
||||
@@ -2284,12 +2313,8 @@ class SkillsProvider(ContextProvider):
|
||||
if skill is None:
|
||||
return f"Error: Skill '{skill_name}' not found."
|
||||
|
||||
# Find resource by name (case-insensitive)
|
||||
resource_name_lower = resource_name.lower()
|
||||
for resource in skill.resources:
|
||||
if resource.name.lower() == resource_name_lower:
|
||||
break
|
||||
else:
|
||||
resource = await skill.get_resource(resource_name)
|
||||
if resource is None:
|
||||
return f"Error: Resource '{resource_name}' not found in skill '{skill_name}'."
|
||||
|
||||
try:
|
||||
@@ -2481,27 +2506,29 @@ class FileSkillsSource(SkillsSource):
|
||||
)
|
||||
continue
|
||||
|
||||
file_skill = FileSkill(
|
||||
frontmatter=frontmatter,
|
||||
content=content,
|
||||
path=skill_path,
|
||||
)
|
||||
|
||||
# Discover and attach file-based resources
|
||||
# Discover file-based resources
|
||||
resources: list[SkillResource] = []
|
||||
for rn in FileSkillsSource._discover_resource_files(
|
||||
skill_path, self._resource_extensions, self._resource_directories
|
||||
):
|
||||
resource_full_path = FileSkillsSource._get_validated_resource_path(skill_path, rn)
|
||||
file_skill.resources.append(_FileSkillResource(name=rn, full_path=resource_full_path))
|
||||
resources.append(_FileSkillResource(name=rn, full_path=resource_full_path))
|
||||
|
||||
# Discover and attach file-based scripts as SkillScript instances
|
||||
# Discover file-based scripts
|
||||
scripts: list[SkillScript] = []
|
||||
for sn in FileSkillsSource._discover_script_files(
|
||||
skill_path, self._script_extensions, self._script_directories
|
||||
):
|
||||
script_full_path = os.path.normpath(os.path.join(skill_path, sn)) # noqa: ASYNC240
|
||||
file_skill.scripts.append(
|
||||
FileSkillScript(name=sn, full_path=script_full_path, runner=self._script_runner)
|
||||
)
|
||||
scripts.append(FileSkillScript(name=sn, full_path=script_full_path, runner=self._script_runner))
|
||||
|
||||
file_skill = FileSkill(
|
||||
frontmatter=frontmatter,
|
||||
content=content,
|
||||
path=skill_path,
|
||||
resources=resources,
|
||||
scripts=scripts,
|
||||
)
|
||||
|
||||
skills[file_skill.frontmatter.name] = file_skill
|
||||
logger.info("Loaded skill: %s", file_skill.frontmatter.name)
|
||||
|
||||
@@ -7,6 +7,7 @@ This module lazily re-exports objects from:
|
||||
|
||||
Supported classes:
|
||||
- A2AAgent
|
||||
- A2AAgentSession
|
||||
- A2AExecutor
|
||||
"""
|
||||
|
||||
@@ -15,7 +16,7 @@ from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_a2a"
|
||||
PACKAGE_NAME = "agent-framework-a2a"
|
||||
_IMPORTS = ["A2AAgent", "A2AExecutor"]
|
||||
_IMPORTS = ["A2AAgent", "A2AAgentSession", "A2AExecutor"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_a2a import A2AAgent, A2AExecutor
|
||||
from agent_framework_a2a import A2AAgent, A2AAgentSession, A2AExecutor
|
||||
|
||||
__all__ = ["A2AAgent", "A2AExecutor"]
|
||||
__all__ = ["A2AAgent", "A2AAgentSession", "A2AExecutor"]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -252,8 +252,8 @@ async def test_todo_provider_runs_with_file_store(tmp_path: Path, chat_client_ba
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
add_todos = _tool_by_name(tools, "add_todos")
|
||||
get_all_todos = _tool_by_name(tools, "get_all_todos")
|
||||
add_todos = _tool_by_name(tools, "todos_add")
|
||||
get_all_todos = _tool_by_name(tools, "todos_get_all")
|
||||
|
||||
await add_todos.invoke(arguments={"todos": [{"title": "Persist me"}]})
|
||||
state_path = tmp_path / "session-1" / "todos.todo.json"
|
||||
@@ -283,11 +283,11 @@ async def test_todo_provider_tools_manage_session_state(
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
add_todos = _tool_by_name(tools, "add_todos")
|
||||
complete_todos = _tool_by_name(tools, "complete_todos")
|
||||
remove_todos = _tool_by_name(tools, "remove_todos")
|
||||
get_remaining_todos = _tool_by_name(tools, "get_remaining_todos")
|
||||
get_all_todos = _tool_by_name(tools, "get_all_todos")
|
||||
add_todos = _tool_by_name(tools, "todos_add")
|
||||
complete_todos = _tool_by_name(tools, "todos_complete")
|
||||
remove_todos = _tool_by_name(tools, "todos_remove")
|
||||
get_remaining_todos = _tool_by_name(tools, "todos_get_remaining")
|
||||
get_all_todos = _tool_by_name(tools, "todos_get_all")
|
||||
|
||||
add_result = await add_todos.invoke(
|
||||
arguments={
|
||||
@@ -302,7 +302,7 @@ async def test_todo_provider_tools_manage_session_state(
|
||||
{"id": 2, "title": "Ship feature", "description": None, "is_complete": False},
|
||||
]
|
||||
|
||||
complete_result = await complete_todos.invoke(arguments={"ids": [1]})
|
||||
complete_result = await complete_todos.invoke(arguments={"items": [{"id": 1, "reason": "Tests written"}]})
|
||||
assert json.loads(complete_result[0].text) == {"completed": 1}
|
||||
|
||||
remaining_result = await get_remaining_todos.invoke()
|
||||
@@ -334,16 +334,16 @@ async def test_todo_provider_serializes_concurrent_mutations(
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
add_todos = _tool_by_name(tools, "add_todos")
|
||||
complete_todos = _tool_by_name(tools, "complete_todos")
|
||||
get_all_todos = _tool_by_name(tools, "get_all_todos")
|
||||
add_todos = _tool_by_name(tools, "todos_add")
|
||||
complete_todos = _tool_by_name(tools, "todos_complete")
|
||||
get_all_todos = _tool_by_name(tools, "todos_get_all")
|
||||
|
||||
await add_todos.invoke(arguments={"todos": [{"title": f"Existing {index}"} for index in range(1, 6)]})
|
||||
|
||||
await asyncio.gather(
|
||||
add_todos.invoke(arguments={"todos": [{"title": "Add A1"}, {"title": "Add A2"}]}),
|
||||
add_todos.invoke(arguments={"todos": [{"title": "Add B1"}, {"title": "Add B2"}]}),
|
||||
complete_todos.invoke(arguments={"ids": [1, 2, 3, 4, 5]}),
|
||||
complete_todos.invoke(arguments={"items": [{"id": i, "reason": "Done"} for i in range(1, 6)]}),
|
||||
)
|
||||
|
||||
get_all_result = await get_all_todos.invoke()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,10 +38,8 @@ from ._executors_agents import (
|
||||
)
|
||||
from ._executors_basic import (
|
||||
BASIC_ACTION_EXECUTORS,
|
||||
AppendValueExecutor,
|
||||
ClearAllVariablesExecutor,
|
||||
CreateConversationExecutor,
|
||||
EmitEventExecutor,
|
||||
ResetVariableExecutor,
|
||||
SendActivityExecutor,
|
||||
SetMultipleVariablesExecutor,
|
||||
@@ -61,12 +59,10 @@ from ._executors_control_flow import (
|
||||
)
|
||||
from ._executors_external_input import (
|
||||
EXTERNAL_INPUT_EXECUTORS,
|
||||
ConfirmationExecutor,
|
||||
ExternalInputRequest,
|
||||
ExternalInputResponse,
|
||||
QuestionExecutor,
|
||||
RequestExternalInputExecutor,
|
||||
WaitForInputExecutor,
|
||||
)
|
||||
from ._executors_http import (
|
||||
HTTP_ACTION_EXECUTORS,
|
||||
@@ -122,11 +118,9 @@ __all__ = [
|
||||
"AgentExternalInputRequest",
|
||||
"AgentExternalInputResponse",
|
||||
"AgentResult",
|
||||
"AppendValueExecutor",
|
||||
"BaseToolExecutor",
|
||||
"BreakLoopExecutor",
|
||||
"ClearAllVariablesExecutor",
|
||||
"ConfirmationExecutor",
|
||||
"ContinueLoopExecutor",
|
||||
"ConversationData",
|
||||
"CreateConversationExecutor",
|
||||
@@ -139,7 +133,6 @@ __all__ = [
|
||||
"DeclarativeWorkflowState",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"EmitEventExecutor",
|
||||
"EndConversationExecutor",
|
||||
"EndWorkflowExecutor",
|
||||
"ExternalInputRequest",
|
||||
@@ -173,7 +166,6 @@ __all__ = [
|
||||
"ToolApprovalResponse",
|
||||
"ToolApprovalState",
|
||||
"ToolInvocationResult",
|
||||
"WaitForInputExecutor",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
]
|
||||
|
||||
+2
-2
@@ -915,9 +915,9 @@ class ActionComplete:
|
||||
|
||||
@dataclass
|
||||
class ConditionResult:
|
||||
"""Result of evaluating a condition (If/Switch).
|
||||
"""Result of evaluating a condition (If/ConditionGroup).
|
||||
|
||||
This message is output by ConditionEvaluatorExecutor and SwitchEvaluatorExecutor
|
||||
This message is output by ConditionEvaluatorExecutor and ConditionGroupEvaluatorExecutor
|
||||
to indicate which branch should be taken.
|
||||
"""
|
||||
|
||||
|
||||
+66
-85
@@ -7,7 +7,7 @@ This module provides the DeclarativeWorkflowBuilder which is analogous to
|
||||
action definitions and creates a proper workflow graph with:
|
||||
- Executor nodes for each action
|
||||
- Edges for sequential flow
|
||||
- Condition evaluator executors for If/Switch that ensure first-match semantics
|
||||
- Condition evaluator executors for If/ConditionGroup that ensure first-match semantics
|
||||
- Loop edges for foreach
|
||||
"""
|
||||
|
||||
@@ -38,7 +38,6 @@ from ._executors_control_flow import (
|
||||
ForeachNextExecutor,
|
||||
IfConditionEvaluatorExecutor,
|
||||
JoinExecutor,
|
||||
SwitchEvaluatorExecutor,
|
||||
)
|
||||
from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS
|
||||
from ._executors_http import HTTP_ACTION_EXECUTORS, HttpRequestActionExecutor
|
||||
@@ -64,7 +63,6 @@ ALL_ACTION_EXECUTORS = {
|
||||
# Action kinds that terminate control flow (no fall-through to successor)
|
||||
# These actions transfer control elsewhere and should not have sequential edges to the next action
|
||||
TERMINATOR_ACTIONS = frozenset({
|
||||
"Goto",
|
||||
"GotoAction",
|
||||
"BreakLoop",
|
||||
"ContinueLoop",
|
||||
@@ -80,18 +78,16 @@ TERMINATOR_ACTIONS = frozenset({
|
||||
ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
|
||||
"SetValue": ["path"],
|
||||
"SetVariable": ["variable"],
|
||||
"AppendValue": ["path", "value"],
|
||||
"SendActivity": ["activity"],
|
||||
"InvokeAzureAgent": ["agent"],
|
||||
"Goto": ["target"],
|
||||
"GotoAction": ["actionId"],
|
||||
"Foreach": ["items", "actions"],
|
||||
"Foreach": ["source", "actions"],
|
||||
"If": ["condition"],
|
||||
"Switch": ["value"], # Switch can use value/cases or conditions (ConditionGroup style)
|
||||
"ConditionGroup": ["conditions"],
|
||||
"Question": ["question", "variable"],
|
||||
"RequestExternalInput": ["prompt", "variable"],
|
||||
"RequestHumanInput": ["variable"],
|
||||
"WaitForHumanInput": ["variable"],
|
||||
"EmitEvent": ["event"],
|
||||
"InvokeFunctionTool": ["functionName"],
|
||||
"HttpRequestAction": ["url"],
|
||||
"InvokeMcpTool": ["serverUrl", "toolName"],
|
||||
@@ -101,11 +97,14 @@ ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
|
||||
# Key: "ActionKind.field", Value: list of alternates that satisfy the requirement
|
||||
ACTION_ALTERNATE_FIELDS: dict[str, list[str]] = {
|
||||
"SetValue.path": ["variable"],
|
||||
"Goto.target": ["actionId"],
|
||||
"GotoAction.actionId": ["target"],
|
||||
"InvokeAzureAgent.agent": ["agentName"],
|
||||
"Foreach.items": ["itemsSource", "source"], # source is used in some schemas
|
||||
"Switch.value": ["conditions"], # Switch can be condition-based instead of value-based
|
||||
# Top-level alternates that satisfy the nested-shape requirements without forcing
|
||||
# callers to spell every field in its long form.
|
||||
"Question.question": ["text"],
|
||||
"Question.variable": ["property"],
|
||||
"RequestExternalInput.prompt": ["message"],
|
||||
"RequestExternalInput.variable": ["property"],
|
||||
}
|
||||
|
||||
|
||||
@@ -115,9 +114,9 @@ class DeclarativeWorkflowBuilder:
|
||||
This builder transforms declarative action definitions into a proper
|
||||
workflow graph with executor nodes and edges. It handles:
|
||||
- Sequential actions (simple edges)
|
||||
- Conditional branching (If/Switch with condition edges)
|
||||
- Conditional branching (If/ConditionGroup with condition edges)
|
||||
- Loops (Foreach with loop edges)
|
||||
- Jumps (Goto with target edges)
|
||||
- Jumps (GotoAction with target edges)
|
||||
|
||||
Example usage:
|
||||
yaml_def = {
|
||||
@@ -299,7 +298,7 @@ class DeclarativeWorkflowBuilder:
|
||||
raise ValueError(f"Action '{kind}' is missing required field '{field}'. Action: {action_def}")
|
||||
|
||||
# Collect goto targets for circular reference detection
|
||||
if kind in ("Goto", "GotoAction"):
|
||||
if kind == "GotoAction":
|
||||
target = action_def.get("target") or action_def.get("actionId")
|
||||
if target:
|
||||
goto_targets.append((target, explicit_id))
|
||||
@@ -313,13 +312,18 @@ class DeclarativeWorkflowBuilder:
|
||||
if else_actions:
|
||||
self._validate_actions_recursive(else_actions, seen_ids, goto_targets, defined_ids)
|
||||
|
||||
elif kind in ("Switch", "ConditionGroup"):
|
||||
cases = action_def.get("cases", action_def.get("conditions", []))
|
||||
for case in cases:
|
||||
case_actions = case.get("actions", [])
|
||||
if case_actions:
|
||||
self._validate_actions_recursive(case_actions, seen_ids, goto_targets, defined_ids)
|
||||
else_actions = action_def.get("elseActions", action_def.get("else", action_def.get("default", [])))
|
||||
elif kind == "ConditionGroup":
|
||||
for forbidden in ("else", "default"):
|
||||
if forbidden in action_def:
|
||||
raise ValueError(
|
||||
f"Action 'ConditionGroup' field '{forbidden}' is not supported; use 'elseActions' instead."
|
||||
)
|
||||
conditions = action_def.get("conditions", [])
|
||||
for condition_branch in conditions:
|
||||
branch_actions = condition_branch.get("actions", [])
|
||||
if branch_actions:
|
||||
self._validate_actions_recursive(branch_actions, seen_ids, goto_targets, defined_ids)
|
||||
else_actions = action_def.get("elseActions", [])
|
||||
if else_actions:
|
||||
self._validate_actions_recursive(else_actions, seen_ids, goto_targets, defined_ids)
|
||||
|
||||
@@ -362,7 +366,8 @@ class DeclarativeWorkflowBuilder:
|
||||
# Check for direct self-reference
|
||||
if source_id and target_id == source_id:
|
||||
raise ValueError(
|
||||
f"Action '{source_id}' has a direct self-referencing Goto, which would cause an infinite loop."
|
||||
f"Action '{source_id}' has a direct self-referencing GotoAction, "
|
||||
"which would cause an infinite loop."
|
||||
)
|
||||
|
||||
def _resolve_pending_gotos(self, builder: WorkflowBuilder) -> None:
|
||||
@@ -380,7 +385,7 @@ class DeclarativeWorkflowBuilder:
|
||||
builder.add_edge(source=goto_executor, target=target_executor)
|
||||
else:
|
||||
available_ids = list(self._executors.keys())
|
||||
raise ValueError(f"Goto target '{target_id}' not found. Available action IDs: {available_ids}")
|
||||
raise ValueError(f"GotoAction target '{target_id}' not found. Available action IDs: {available_ids}")
|
||||
|
||||
def _create_executors_for_actions(
|
||||
self,
|
||||
@@ -453,11 +458,11 @@ class DeclarativeWorkflowBuilder:
|
||||
# Handle special control flow actions
|
||||
if kind == "If":
|
||||
return self._create_if_structure(action_def, builder, parent_context)
|
||||
if kind == "Switch" or kind == "ConditionGroup":
|
||||
return self._create_switch_structure(action_def, builder, parent_context)
|
||||
if kind == "ConditionGroup":
|
||||
return self._create_condition_group_structure(action_def, builder, parent_context)
|
||||
if kind == "Foreach":
|
||||
return self._create_foreach_structure(action_def, builder, parent_context)
|
||||
if kind == "Goto" or kind == "GotoAction":
|
||||
if kind == "GotoAction":
|
||||
return self._create_goto_reference(action_def, builder, parent_context)
|
||||
if kind == "BreakLoop":
|
||||
return self._create_break_executor(action_def, builder, parent_context)
|
||||
@@ -588,7 +593,7 @@ class DeclarativeWorkflowBuilder:
|
||||
|
||||
# Wire evaluator to branches with conditions that check ConditionResult.branch_index
|
||||
# branch_index=0 means "then" branch, branch_index=-1 (ELSE_BRANCH_INDEX) means "else"
|
||||
# For nested If/Switch structures, wire to the evaluator (entry point)
|
||||
# For nested If/ConditionGroup structures, wire to the evaluator (entry point)
|
||||
if then_entry:
|
||||
then_target = self._get_structure_entry(then_entry)
|
||||
builder.add_edge(
|
||||
@@ -634,66 +639,42 @@ class DeclarativeWorkflowBuilder:
|
||||
|
||||
return IfStructure()
|
||||
|
||||
def _create_switch_structure(
|
||||
def _create_condition_group_structure(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
builder: WorkflowBuilder,
|
||||
parent_context: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Create the graph structure for a Switch/ConditionGroup action.
|
||||
"""Create the graph structure for a ConditionGroup action.
|
||||
|
||||
Supports two schema formats:
|
||||
1. ConditionGroup schema (matches .NET):
|
||||
- conditions: list of {condition: expr, actions: [...]}
|
||||
- elseActions: default actions
|
||||
|
||||
2. Switch schema (interpreter style):
|
||||
- value: expression to match
|
||||
- cases: list of {match: value, actions: [...]}
|
||||
- default: default actions
|
||||
|
||||
Both use evaluator executors that output ConditionResult with branch_index
|
||||
for first-match semantics.
|
||||
Evaluates the action's ``conditions`` in order; the first match
|
||||
selects its ``actions`` branch. If none match, ``elseActions`` runs.
|
||||
The structure exposes an evaluator entry point and the per-branch
|
||||
entry/exit pairs used by the caller to wire downstream edges.
|
||||
|
||||
Args:
|
||||
action_def: The Switch/ConditionGroup action definition
|
||||
action_def: The ConditionGroup action definition
|
||||
builder: The workflow builder
|
||||
parent_context: Context from parent
|
||||
|
||||
Returns:
|
||||
A SwitchStructure containing branch info for wiring
|
||||
A ConditionGroupStructure containing branch info for wiring
|
||||
"""
|
||||
action_id = action_def.get("id") or f"Switch_{self._action_index}"
|
||||
action_id = action_def.get("id") or f"ConditionGroup_{self._action_index}"
|
||||
self._action_index += 1
|
||||
|
||||
# Pass the Switch's ID as context for child action naming
|
||||
# Pass the ConditionGroup's ID as context for child action naming
|
||||
branch_context = {
|
||||
**(parent_context or {}),
|
||||
"parent_id": action_id,
|
||||
}
|
||||
|
||||
# Detect schema type:
|
||||
# - If "cases" present: interpreter Switch schema (value/cases/default)
|
||||
# - If "conditions" present: ConditionGroup schema (conditions/elseActions)
|
||||
cases = action_def.get("cases", [])
|
||||
conditions = action_def.get("conditions", [])
|
||||
|
||||
if cases:
|
||||
# Interpreter Switch schema: value/cases/default
|
||||
evaluator: DeclarativeActionExecutor = SwitchEvaluatorExecutor(
|
||||
action_def,
|
||||
cases,
|
||||
id=f"{action_id}_eval",
|
||||
)
|
||||
branch_items = cases
|
||||
else:
|
||||
# ConditionGroup schema: conditions/elseActions
|
||||
evaluator = ConditionGroupEvaluatorExecutor(
|
||||
action_def,
|
||||
conditions,
|
||||
id=f"{action_id}_eval",
|
||||
)
|
||||
branch_items = conditions
|
||||
evaluator: DeclarativeActionExecutor = ConditionGroupEvaluatorExecutor(
|
||||
action_def,
|
||||
conditions,
|
||||
id=f"{action_id}_eval",
|
||||
)
|
||||
|
||||
self._executors[evaluator.id] = evaluator
|
||||
|
||||
@@ -701,7 +682,7 @@ class DeclarativeWorkflowBuilder:
|
||||
branch_entries: list[tuple[int, Any]] = [] # (branch_index, entry_executor)
|
||||
branch_exits: list[Any] = [] # All exits that need wiring to successor
|
||||
|
||||
for i, item in enumerate(branch_items):
|
||||
for i, item in enumerate(conditions):
|
||||
branch_actions = item.get("actions", [])
|
||||
# Use branch-specific context
|
||||
case_context = {**branch_context, "parent_id": f"{action_id}_case{i}"}
|
||||
@@ -714,9 +695,7 @@ class DeclarativeWorkflowBuilder:
|
||||
if branch_exit:
|
||||
branch_exits.append(branch_exit)
|
||||
|
||||
# Handle else/default branch
|
||||
# .NET uses "elseActions", interpreter uses "else" or "default"
|
||||
else_actions = action_def.get("elseActions", action_def.get("else", action_def.get("default", [])))
|
||||
else_actions = action_def.get("elseActions", [])
|
||||
default_entry = None
|
||||
default_passthrough = None
|
||||
if else_actions:
|
||||
@@ -734,7 +713,7 @@ class DeclarativeWorkflowBuilder:
|
||||
branch_exits.append(default_passthrough)
|
||||
|
||||
# Wire evaluator to branches with conditions that check ConditionResult.branch_index
|
||||
# For nested If/Switch structures, wire to the evaluator (entry point)
|
||||
# For nested If/ConditionGroup structures, wire to the evaluator (entry point)
|
||||
for branch_index, branch_entry in branch_entries:
|
||||
# Capture branch_index in closure properly using a factory function for type inference
|
||||
def make_branch_condition(expected: int) -> Any:
|
||||
@@ -762,8 +741,8 @@ class DeclarativeWorkflowBuilder:
|
||||
condition=lambda msg: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX,
|
||||
)
|
||||
|
||||
# Create a SwitchStructure to hold all the info needed for wiring
|
||||
class SwitchStructure:
|
||||
# Create a ConditionGroupStructure to hold all the info needed for wiring
|
||||
class ConditionGroupStructure:
|
||||
def __init__(self) -> None:
|
||||
self.id = action_id
|
||||
self.evaluator = evaluator # The entry point for this structure
|
||||
@@ -771,9 +750,9 @@ class DeclarativeWorkflowBuilder:
|
||||
self.default_entry = default_entry
|
||||
self.default_passthrough = default_passthrough
|
||||
self.branch_exits = branch_exits # All exits that need wiring to successor
|
||||
self._is_switch_structure = True
|
||||
self._is_condition_group_structure = True
|
||||
|
||||
return SwitchStructure()
|
||||
return ConditionGroupStructure()
|
||||
|
||||
def _create_foreach_structure(
|
||||
self,
|
||||
@@ -823,7 +802,7 @@ class DeclarativeWorkflowBuilder:
|
||||
body_entry = self._create_executors_for_actions(body_actions, builder, loop_context)
|
||||
|
||||
if body_entry:
|
||||
# For nested If/Switch structures, wire to the evaluator (entry point)
|
||||
# For nested If/ConditionGroup structures, wire to the evaluator (entry point)
|
||||
body_target = self._get_structure_entry(body_entry)
|
||||
|
||||
# Init -> body (when has_next=True)
|
||||
@@ -835,7 +814,7 @@ class DeclarativeWorkflowBuilder:
|
||||
|
||||
# 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
|
||||
# terminators (Break/Continue), and returns nested If/ConditionGroup
|
||||
# structures so _get_source_exits can flatten their branch exits.
|
||||
body_exit = self._get_branch_exit(body_entry)
|
||||
if body_exit is not None:
|
||||
@@ -963,8 +942,8 @@ class DeclarativeWorkflowBuilder:
|
||||
"""Add a sequential edge between two executors.
|
||||
|
||||
Handles control flow structures:
|
||||
- If source is a structure (If/Switch), wire from all branch exits
|
||||
- If target is a structure (If/Switch), wire with conditional edges to branches
|
||||
- If source is a structure (If/ConditionGroup), wire from all branch exits
|
||||
- If target is a structure (If/ConditionGroup), wire with conditional edges to branches
|
||||
"""
|
||||
# Get all source exit points
|
||||
source_exits = self._get_source_exits(source)
|
||||
@@ -999,12 +978,12 @@ class DeclarativeWorkflowBuilder:
|
||||
) -> None:
|
||||
"""Wire a single source executor to a target (which may be a structure).
|
||||
|
||||
For If/Switch structures, wire to the evaluator executor. The evaluator
|
||||
For If/ConditionGroup structures, wire to the evaluator executor. The evaluator
|
||||
handles condition evaluation and outputs ConditionResult, which is then
|
||||
routed to the appropriate branch by edges created in _create_*_structure.
|
||||
"""
|
||||
# Check if target is an IfStructure or SwitchStructure (wire to evaluator)
|
||||
if getattr(target, "_is_if_structure", False) or getattr(target, "_is_switch_structure", False):
|
||||
# Check if target is an IfStructure or ConditionGroupStructure (wire to evaluator)
|
||||
if getattr(target, "_is_if_structure", False) or getattr(target, "_is_condition_group_structure", False):
|
||||
# Wire from source to the evaluator - the evaluator then routes to branches
|
||||
builder.add_edge(source=source, target=target.evaluator)
|
||||
|
||||
@@ -1015,7 +994,7 @@ class DeclarativeWorkflowBuilder:
|
||||
def _get_structure_entry(self, entry: Any) -> Any:
|
||||
"""Get the entry point executor for a structure or regular executor.
|
||||
|
||||
For If/Switch structures, returns the evaluator. For regular executors,
|
||||
For If/ConditionGroup structures, returns the evaluator. For regular executors,
|
||||
returns the executor itself.
|
||||
|
||||
Args:
|
||||
@@ -1024,14 +1003,16 @@ class DeclarativeWorkflowBuilder:
|
||||
Returns:
|
||||
The entry point executor
|
||||
"""
|
||||
is_structure = getattr(entry, "_is_if_structure", False) or getattr(entry, "_is_switch_structure", False)
|
||||
is_structure = getattr(entry, "_is_if_structure", False) or getattr(
|
||||
entry, "_is_condition_group_structure", False
|
||||
)
|
||||
return entry.evaluator if is_structure else entry
|
||||
|
||||
def _get_branch_exit(self, branch_entry: Any) -> Any | None:
|
||||
"""Get the exit point of a branch for downstream wiring.
|
||||
|
||||
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
|
||||
the nested If/ConditionGroup 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.
|
||||
"""
|
||||
|
||||
-65
@@ -179,28 +179,6 @@ class SetMultipleVariablesExecutor(DeclarativeActionExecutor):
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class AppendValueExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the AppendValue action."""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the AppendValue action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
path = self._action_def.get("path")
|
||||
value = self._action_def.get("value")
|
||||
|
||||
if path:
|
||||
evaluated_value = state.eval_if_expression(value)
|
||||
state.append(path, evaluated_value)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class ResetVariableExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the ResetVariable action."""
|
||||
|
||||
@@ -279,47 +257,6 @@ class SendActivityExecutor(DeclarativeActionExecutor):
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class EmitEventExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the EmitEvent action.
|
||||
|
||||
Emits a custom event to the workflow event stream.
|
||||
|
||||
Supports two schema formats:
|
||||
1. Graph mode: eventName, eventValue
|
||||
2. Interpreter mode: event.name, event.data
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Handle the EmitEvent action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
# Support both schema formats:
|
||||
# - Graph mode: eventName, eventValue
|
||||
# - Interpreter mode: event.name, event.data
|
||||
event_def = self._action_def.get("event", {})
|
||||
event_name = self._action_def.get("eventName") or event_def.get("name", "")
|
||||
event_value = self._action_def.get("eventValue")
|
||||
if event_value is None:
|
||||
event_value = event_def.get("data")
|
||||
|
||||
if event_name:
|
||||
evaluated_name = state.eval_if_expression(event_name)
|
||||
evaluated_value = state.eval_if_expression(event_value)
|
||||
|
||||
event_data = {
|
||||
"eventName": evaluated_name,
|
||||
"eventValue": evaluated_value,
|
||||
}
|
||||
await ctx.yield_output(event_data)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class EditTableExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the EditTable action.
|
||||
|
||||
@@ -628,11 +565,9 @@ BASIC_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"SetVariable": SetVariableExecutor,
|
||||
"SetTextVariable": SetTextVariableExecutor,
|
||||
"SetMultipleVariables": SetMultipleVariablesExecutor,
|
||||
"AppendValue": AppendValueExecutor,
|
||||
"ResetVariable": ResetVariableExecutor,
|
||||
"ClearAllVariables": ClearAllVariablesExecutor,
|
||||
"SendActivity": SendActivityExecutor,
|
||||
"EmitEvent": EmitEventExecutor,
|
||||
"ParseValue": ParseValueExecutor,
|
||||
"EditTable": EditTableExecutor,
|
||||
"EditTableV2": EditTableV2Executor,
|
||||
|
||||
+15
-110
@@ -3,7 +3,7 @@
|
||||
"""Control flow executors for the graph-based declarative workflow system.
|
||||
|
||||
Control flow in the graph-based system is handled differently than the interpreter:
|
||||
- If/Switch: Condition evaluation happens in a dedicated evaluator executor that
|
||||
- If/ConditionGroup: Condition evaluation happens in a dedicated evaluator executor that
|
||||
returns a ConditionResult with the first-matching branch index. Edge conditions
|
||||
then check the branch_index to route to the correct branch. This ensures only
|
||||
one branch executes (first-match semantics), matching the interpreter behavior.
|
||||
@@ -39,7 +39,7 @@ ELSE_BRANCH_INDEX = -1
|
||||
|
||||
|
||||
class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
"""Evaluates conditions for ConditionGroup/Switch and outputs the first-matching branch.
|
||||
"""Evaluates conditions for ConditionGroup and outputs the first-matching branch.
|
||||
|
||||
This executor implements first-match semantics by evaluating conditions sequentially
|
||||
and outputting a ConditionResult with the index of the first matching branch.
|
||||
@@ -59,7 +59,7 @@ class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
"""Initialize the condition evaluator.
|
||||
|
||||
Args:
|
||||
action_def: The ConditionGroup/Switch action definition
|
||||
action_def: The ConditionGroup action definition
|
||||
conditions: List of condition items, each with 'condition' and optional 'id'
|
||||
id: Optional executor ID
|
||||
"""
|
||||
@@ -99,71 +99,6 @@ class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
|
||||
|
||||
|
||||
class SwitchEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
"""Evaluates a Switch action by matching a value against cases.
|
||||
|
||||
The Switch action uses a different schema than ConditionGroup:
|
||||
- value: expression to evaluate once
|
||||
- cases: list of {match: value_to_match, actions: [...]}
|
||||
- default: default actions if no case matches
|
||||
|
||||
This evaluator evaluates the value expression once, then compares it
|
||||
against each case's match value sequentially. First match wins.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
cases: list[dict[str, Any]],
|
||||
*,
|
||||
id: str | None = None,
|
||||
):
|
||||
"""Initialize the switch evaluator.
|
||||
|
||||
Args:
|
||||
action_def: The Switch action definition (contains 'value' expression)
|
||||
cases: List of case items, each with 'match' and optional 'actions'
|
||||
id: Optional executor ID
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._cases = cases
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ConditionResult],
|
||||
) -> None:
|
||||
"""Evaluate the switch value and find the first matching case."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
value_expr = self._action_def.get("value")
|
||||
if not value_expr:
|
||||
# No value to switch on - use default
|
||||
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
|
||||
return
|
||||
|
||||
# Evaluate the switch value once
|
||||
switch_value = state.eval_if_expression(value_expr)
|
||||
|
||||
# Compare against each case's match value
|
||||
for index, case_item in enumerate(self._cases):
|
||||
match_expr = case_item.get("match")
|
||||
if match_expr is None:
|
||||
continue
|
||||
|
||||
# Evaluate the match value
|
||||
match_value = state.eval_if_expression(match_expr)
|
||||
|
||||
if switch_value == match_value:
|
||||
# Found matching case
|
||||
await ctx.send_message(ConditionResult(matched=True, branch_index=index, value=switch_value))
|
||||
return
|
||||
|
||||
# No case matched - use default branch
|
||||
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
|
||||
|
||||
|
||||
class IfConditionEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
"""Evaluates a single If condition and outputs a ConditionResult.
|
||||
|
||||
@@ -221,12 +156,7 @@ class ForeachInitExecutor(DeclarativeActionExecutor):
|
||||
"""Initialize the loop and check for first item."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
# Support multiple schema formats:
|
||||
# - Graph mode: itemsSource, items
|
||||
# - Interpreter mode: source
|
||||
items_expr = (
|
||||
self._action_def.get("itemsSource") or self._action_def.get("items") or self._action_def.get("source")
|
||||
)
|
||||
items_expr = self._action_def.get("source")
|
||||
items_raw: Any = state.eval_if_expression(items_expr) or []
|
||||
|
||||
items: list[Any]
|
||||
@@ -244,25 +174,12 @@ class ForeachInitExecutor(DeclarativeActionExecutor):
|
||||
}
|
||||
state.set_state_data(state_data)
|
||||
|
||||
# Check if we have items
|
||||
if items:
|
||||
# Set the iteration variable
|
||||
# Support multiple schema formats:
|
||||
# - Graph mode: iteratorVariable, item (default "Local.item")
|
||||
# - Interpreter mode: itemName (default "item", stored in Local scope)
|
||||
item_var = self._action_def.get("iteratorVariable") or self._action_def.get("item")
|
||||
if not item_var:
|
||||
# Interpreter mode: itemName defaults to "item", store in Local scope
|
||||
item_name = self._action_def.get("itemName", "item")
|
||||
item_var = f"Local.{item_name}"
|
||||
|
||||
# Support multiple schema formats for index:
|
||||
# - Graph mode: indexVariable, index
|
||||
# - Interpreter mode: indexName (default "index", stored in Local scope)
|
||||
index_var = self._action_def.get("indexVariable") or self._action_def.get("index")
|
||||
if not index_var and "indexName" in self._action_def:
|
||||
index_name = self._action_def.get("indexName", "index")
|
||||
index_var = f"Local.{index_name}"
|
||||
# Bind the current item and (when requested) the index under the Local scope.
|
||||
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
|
||||
index_var = (
|
||||
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
|
||||
)
|
||||
|
||||
state.set(item_var, items[0])
|
||||
if index_var:
|
||||
@@ -325,23 +242,11 @@ class ForeachNextExecutor(DeclarativeActionExecutor):
|
||||
loop_state["index"] = current_index
|
||||
state.set_state_data(state_data)
|
||||
|
||||
# Set the iteration variable
|
||||
# Support multiple schema formats:
|
||||
# - Graph mode: iteratorVariable, item (default "Local.item")
|
||||
# - Interpreter mode: itemName (default "item", stored in Local scope)
|
||||
item_var = self._action_def.get("iteratorVariable") or self._action_def.get("item")
|
||||
if not item_var:
|
||||
# Interpreter mode: itemName defaults to "item", store in Local scope
|
||||
item_name = self._action_def.get("itemName", "item")
|
||||
item_var = f"Local.{item_name}"
|
||||
|
||||
# Support multiple schema formats for index:
|
||||
# - Graph mode: indexVariable, index
|
||||
# - Interpreter mode: indexName (default "index", stored in Local scope)
|
||||
index_var = self._action_def.get("indexVariable") or self._action_def.get("index")
|
||||
if not index_var and "indexName" in self._action_def:
|
||||
index_name = self._action_def.get("indexName", "index")
|
||||
index_var = f"Local.{index_name}"
|
||||
# Rebind the current item and (when requested) the index under the Local scope.
|
||||
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
|
||||
index_var = (
|
||||
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
|
||||
)
|
||||
|
||||
state.set(item_var, items[current_index])
|
||||
if index_var:
|
||||
@@ -486,7 +391,7 @@ class EndConversationExecutor(DeclarativeActionExecutor):
|
||||
class JoinExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that joins multiple branches back together.
|
||||
|
||||
Used after If/Switch to merge control flow back to a single path.
|
||||
Used after If/ConditionGroup to merge control flow back to a single path.
|
||||
Also used as passthrough nodes for else/default branches.
|
||||
"""
|
||||
|
||||
|
||||
+47
-148
@@ -2,14 +2,14 @@
|
||||
|
||||
"""External input executors for declarative workflows.
|
||||
|
||||
These executors handle interactions that require external input (user questions,
|
||||
confirmations, etc.), using the request_info pattern to pause the workflow and
|
||||
wait for responses.
|
||||
These executors handle interactions that require external input (user questions
|
||||
and external integrations), using the request_info pattern to pause the workflow
|
||||
and wait for responses.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
WorkflowContext,
|
||||
@@ -23,18 +23,49 @@ from ._declarative_base import (
|
||||
)
|
||||
|
||||
|
||||
def _get_prompt_text(action_def: dict[str, Any], primary_key: str, fallback_key: str) -> Any:
|
||||
"""Return the prompt text from an action definition.
|
||||
|
||||
Accepts a nested ``{primary_key: {"text": ...}}`` mapping, a bare
|
||||
string under ``primary_key``, or a top-level ``fallback_key`` value.
|
||||
"""
|
||||
match action_def.get(primary_key):
|
||||
case {"text": text}:
|
||||
return text
|
||||
case str() as text:
|
||||
return text
|
||||
case _:
|
||||
return action_def.get(fallback_key, "")
|
||||
|
||||
|
||||
def _get_output_path(action_def: dict[str, Any], default: str) -> str:
|
||||
"""Return the state path where the action result should be written.
|
||||
|
||||
Looks at ``variable``, then ``output.property``, then top-level
|
||||
``property``, falling back to ``default``.
|
||||
"""
|
||||
output = action_def.get("output")
|
||||
nested = cast(dict[str, Any], output).get("property") if isinstance(output, dict) else None
|
||||
return action_def.get("variable") or nested or action_def.get("property") or default
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalInputRequest:
|
||||
"""Request for external input (triggers workflow pause).
|
||||
|
||||
Aligns with .NET ExternalInputRequest pattern. Used by Question, Confirmation,
|
||||
WaitForInput, and RequestExternalInput executors to signal that user input is
|
||||
needed. The workflow will pause via request_info and wait for an ExternalInputResponse.
|
||||
Aligns with .NET ExternalInputRequest pattern. Used by Question and
|
||||
RequestExternalInput executors to signal that user input is needed.
|
||||
The workflow will pause via request_info and wait for an ExternalInputResponse.
|
||||
|
||||
Attributes:
|
||||
request_id: Unique identifier for this request.
|
||||
message: The prompt or question to display to the user.
|
||||
request_type: Type of input requested (question, confirmation, user_input, external).
|
||||
request_type: A free-form discriminator describing the kind of input
|
||||
being requested. ``QuestionExecutor`` emits ``"question"`` and
|
||||
``RequestExternalInputExecutor`` defaults to ``"external"``; callers
|
||||
may supply any other string via the ``requestType`` field on a
|
||||
``RequestExternalInput`` action (e.g. ``"approval"``) and it is
|
||||
propagated unchanged.
|
||||
metadata: Additional context (choices, output_property, timeout, etc.).
|
||||
"""
|
||||
|
||||
@@ -75,15 +106,12 @@ class QuestionExecutor(DeclarativeActionExecutor):
|
||||
"""Ask the question and wait for a response."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
question_text = self._action_def.get("text") or self._action_def.get("question", "")
|
||||
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
|
||||
"property", "Local.answer"
|
||||
)
|
||||
question_text = _get_prompt_text(self._action_def, primary_key="question", fallback_key="text")
|
||||
output_property = _get_output_path(self._action_def, default="Local.answer")
|
||||
default_value = self._action_def.get("default", self._action_def.get("defaultValue"))
|
||||
choices = self._action_def.get("choices", [])
|
||||
default_value = self._action_def.get("defaultValue")
|
||||
allow_free_text = self._action_def.get("allowFreeText", True)
|
||||
|
||||
# Evaluate the question text if it's an expression
|
||||
evaluated_question = state.eval_if_expression(question_text)
|
||||
|
||||
# Build choices metadata
|
||||
@@ -139,133 +167,6 @@ class QuestionExecutor(DeclarativeActionExecutor):
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class ConfirmationExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that asks for a yes/no confirmation.
|
||||
|
||||
A specialized version of Question that expects a boolean response.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Ask for confirmation."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
message = self._action_def.get("text") or self._action_def.get("message", "")
|
||||
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
|
||||
"property", "Local.confirmed"
|
||||
)
|
||||
yes_label = self._action_def.get("yesLabel", "Yes")
|
||||
no_label = self._action_def.get("noLabel", "No")
|
||||
default_value = self._action_def.get("defaultValue", False)
|
||||
|
||||
# Evaluate the message if it's an expression
|
||||
evaluated_message = state.eval_if_expression(message)
|
||||
|
||||
# Request confirmation - workflow pauses here
|
||||
await ctx.request_info(
|
||||
ExternalInputRequest(
|
||||
request_id=str(uuid.uuid4()),
|
||||
message=str(evaluated_message),
|
||||
request_type="confirmation",
|
||||
metadata={
|
||||
"output_property": output_property,
|
||||
"yes_label": yes_label,
|
||||
"no_label": no_label,
|
||||
"default_value": default_value,
|
||||
},
|
||||
),
|
||||
ExternalInputResponse,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: ExternalInputRequest,
|
||||
response: ExternalInputResponse,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the user's confirmation response."""
|
||||
state = self._get_state(ctx.state)
|
||||
|
||||
output_property = original_request.metadata.get("output_property", "Local.confirmed")
|
||||
|
||||
# Convert response to boolean
|
||||
if response.value is not None:
|
||||
confirmed = bool(response.value)
|
||||
else:
|
||||
# Interpret common affirmative responses
|
||||
user_input_lower = response.user_input.lower().strip()
|
||||
confirmed = user_input_lower in ("yes", "y", "true", "1", "confirm", "ok")
|
||||
|
||||
if output_property:
|
||||
state.set(output_property, confirmed)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class WaitForInputExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that waits for user input during a conversation.
|
||||
|
||||
Used when the workflow needs to pause and wait for the next user message
|
||||
in a conversational flow.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Wait for user input."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
prompt = self._action_def.get("prompt")
|
||||
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
|
||||
"property", "Local.input"
|
||||
)
|
||||
timeout_seconds = self._action_def.get("timeout")
|
||||
|
||||
# Emit prompt if specified
|
||||
if prompt:
|
||||
evaluated_prompt = state.eval_if_expression(prompt)
|
||||
await ctx.yield_output(str(evaluated_prompt))
|
||||
|
||||
# Request user input - workflow pauses here
|
||||
await ctx.request_info(
|
||||
ExternalInputRequest(
|
||||
request_id=str(uuid.uuid4()),
|
||||
message=str(prompt) if prompt else "Waiting for input...",
|
||||
request_type="user_input",
|
||||
metadata={
|
||||
"output_property": output_property,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
},
|
||||
),
|
||||
ExternalInputResponse,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: ExternalInputRequest,
|
||||
response: ExternalInputResponse,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Handle the user's input."""
|
||||
state = self._get_state(ctx.state)
|
||||
|
||||
output_property = original_request.metadata.get("output_property", "Local.input")
|
||||
|
||||
if output_property:
|
||||
state.set(output_property, response.user_input)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class RequestExternalInputExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that requests external input/approval.
|
||||
|
||||
@@ -282,16 +183,15 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
|
||||
"""Request external input."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
message = _get_prompt_text(self._action_def, primary_key="prompt", fallback_key="message")
|
||||
output_property = _get_output_path(self._action_def, default="Local.externalInput")
|
||||
default_value = self._action_def.get("default")
|
||||
|
||||
request_type = self._action_def.get("requestType", "external")
|
||||
message = self._action_def.get("message", "")
|
||||
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
|
||||
"property", "Local.externalInput"
|
||||
)
|
||||
timeout_seconds = self._action_def.get("timeout")
|
||||
required_fields = self._action_def.get("requiredFields", [])
|
||||
metadata = self._action_def.get("metadata", {})
|
||||
|
||||
# Evaluate the message if it's an expression
|
||||
evaluated_message = state.eval_if_expression(message)
|
||||
|
||||
# Build request metadata
|
||||
@@ -299,6 +199,7 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
|
||||
**metadata,
|
||||
"output_property": output_property,
|
||||
"required_fields": required_fields,
|
||||
"default_value": default_value,
|
||||
}
|
||||
|
||||
if timeout_seconds:
|
||||
@@ -338,7 +239,5 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
|
||||
# Mapping of external input action kinds to executor classes
|
||||
EXTERNAL_INPUT_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"Question": QuestionExecutor,
|
||||
"Confirmation": ConfirmationExecutor,
|
||||
"WaitForInput": WaitForInputExecutor,
|
||||
"RequestExternalInput": RequestExternalInputExecutor,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"httpx>=0.27,<1",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
|
||||
@@ -515,27 +515,6 @@ class TestBasicExecutorsCoverage:
|
||||
assert state.get("Local.b") == 2
|
||||
assert state.get("Local.c") == 3
|
||||
|
||||
async def test_append_value_executor(self, mock_context, mock_state):
|
||||
"""Test AppendValueExecutor."""
|
||||
from agent_framework_declarative._workflows._executors_basic import (
|
||||
AppendValueExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
state.set("Local.items", ["a"])
|
||||
|
||||
action_def = {
|
||||
"kind": "AppendValue",
|
||||
"path": "Local.items",
|
||||
"value": "b",
|
||||
}
|
||||
executor = AppendValueExecutor(action_def)
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
result = state.get("Local.items")
|
||||
assert result == ["a", "b"]
|
||||
|
||||
async def test_reset_variable_executor(self, mock_context, mock_state):
|
||||
"""Test ResetVariableExecutor."""
|
||||
from agent_framework_declarative._workflows._executors_basic import (
|
||||
@@ -632,52 +611,6 @@ class TestBasicExecutorsCoverage:
|
||||
|
||||
mock_context.yield_output.assert_called_once_with("Dynamic message")
|
||||
|
||||
async def test_emit_event_executor_graph_mode(self, mock_context, mock_state):
|
||||
"""Test EmitEventExecutor with graph-mode schema (eventName/eventValue)."""
|
||||
from agent_framework_declarative._workflows._executors_basic import (
|
||||
EmitEventExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "EmitEvent",
|
||||
"eventName": "myEvent",
|
||||
"eventValue": {"key": "value"},
|
||||
}
|
||||
executor = EmitEventExecutor(action_def)
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
mock_context.yield_output.assert_called_once()
|
||||
event_data = mock_context.yield_output.call_args[0][0]
|
||||
assert event_data["eventName"] == "myEvent"
|
||||
assert event_data["eventValue"] == {"key": "value"}
|
||||
|
||||
async def test_emit_event_executor_interpreter_mode(self, mock_context, mock_state):
|
||||
"""Test EmitEventExecutor with interpreter-mode schema (event.name/event.data)."""
|
||||
from agent_framework_declarative._workflows._executors_basic import (
|
||||
EmitEventExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "EmitEvent",
|
||||
"event": {
|
||||
"name": "interpreterEvent",
|
||||
"data": {"payload": "test"},
|
||||
},
|
||||
}
|
||||
executor = EmitEventExecutor(action_def)
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
mock_context.yield_output.assert_called_once()
|
||||
event_data = mock_context.yield_output.call_args[0][0]
|
||||
assert event_data["eventName"] == "interpreterEvent"
|
||||
assert event_data["eventValue"] == {"payload": "test"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent Executors Tests - Covering _executors_agents.py gaps
|
||||
@@ -1155,8 +1088,8 @@ class TestControlFlowCoverage:
|
||||
"""Tests for control flow executors covering uncovered code paths."""
|
||||
|
||||
@_requires_powerfx
|
||||
async def test_foreach_with_source_alias(self, mock_context, mock_state):
|
||||
"""Test ForeachInitExecutor with 'source' alias (interpreter mode)."""
|
||||
async def test_foreach_with_source(self, mock_context, mock_state):
|
||||
"""Test ForeachInitExecutor with the 'source' field."""
|
||||
from agent_framework_declarative._workflows._executors_control_flow import (
|
||||
ForeachInitExecutor,
|
||||
)
|
||||
@@ -1205,8 +1138,8 @@ class TestControlFlowCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.data",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.data",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachNextExecutor(action_def, init_executor_id="foreach_init")
|
||||
|
||||
@@ -1217,81 +1150,6 @@ class TestControlFlowCoverage:
|
||||
assert msg.current_index == 1
|
||||
assert msg.current_item == "b"
|
||||
|
||||
@_requires_powerfx
|
||||
async def test_switch_evaluator_with_value_cases(self, mock_context, mock_state):
|
||||
"""Test SwitchEvaluatorExecutor with value/cases schema."""
|
||||
from agent_framework_declarative._workflows._executors_control_flow import (
|
||||
SwitchEvaluatorExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
state.set("Local.status", "pending")
|
||||
|
||||
action_def = {
|
||||
"kind": "Switch",
|
||||
"value": "=Local.status",
|
||||
}
|
||||
cases = [
|
||||
{"match": "active"},
|
||||
{"match": "pending"},
|
||||
]
|
||||
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
msg = mock_context.send_message.call_args[0][0]
|
||||
assert isinstance(msg, ConditionResult)
|
||||
assert msg.matched is True
|
||||
assert msg.branch_index == 1 # Second case matched
|
||||
|
||||
@_requires_powerfx
|
||||
async def test_switch_evaluator_default_case(self, mock_context, mock_state):
|
||||
"""Test SwitchEvaluatorExecutor falls through to default."""
|
||||
from agent_framework_declarative._workflows._executors_control_flow import (
|
||||
SwitchEvaluatorExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
state.set("Local.status", "unknown")
|
||||
|
||||
action_def = {
|
||||
"kind": "Switch",
|
||||
"value": "=Local.status",
|
||||
}
|
||||
cases = [
|
||||
{"match": "active"},
|
||||
{"match": "pending"},
|
||||
]
|
||||
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
msg = mock_context.send_message.call_args[0][0]
|
||||
assert isinstance(msg, ConditionResult)
|
||||
assert msg.matched is False
|
||||
assert msg.branch_index == -1 # Default case
|
||||
|
||||
async def test_switch_evaluator_no_value(self, mock_context, mock_state):
|
||||
"""Test SwitchEvaluatorExecutor with no value defaults to else."""
|
||||
from agent_framework_declarative._workflows._executors_control_flow import (
|
||||
SwitchEvaluatorExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {"kind": "Switch"} # No value
|
||||
cases = [{"match": "x"}]
|
||||
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
msg = mock_context.send_message.call_args[0][0]
|
||||
assert isinstance(msg, ConditionResult)
|
||||
assert msg.branch_index == -1
|
||||
|
||||
async def test_join_executor_accepts_condition_result(self, mock_context, mock_state):
|
||||
"""Test JoinExecutor accepts ConditionResult as trigger."""
|
||||
from agent_framework_declarative._workflows._executors_control_flow import (
|
||||
@@ -1357,8 +1215,8 @@ class TestControlFlowCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.data",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.data",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachNextExecutor(action_def, init_executor_id="missing_loop")
|
||||
|
||||
@@ -1391,8 +1249,8 @@ class TestControlFlowCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.data",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.data",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
|
||||
|
||||
@@ -1425,8 +1283,8 @@ class TestControlFlowCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.data",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.data",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
|
||||
|
||||
@@ -1459,8 +1317,8 @@ class TestControlFlowCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.data",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.data",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
|
||||
|
||||
@@ -1719,60 +1577,6 @@ class TestDeclarativeActionExecutorBase:
|
||||
class TestHumanInputExecutorsCoverage:
|
||||
"""Tests for human input executors covering uncovered code paths."""
|
||||
|
||||
async def test_wait_for_input_executor_with_prompt(self, mock_context, mock_state):
|
||||
"""Test WaitForInputExecutor with prompt."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
WaitForInputExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "WaitForInput",
|
||||
"prompt": "Please enter your name:",
|
||||
"property": "Local.userName",
|
||||
"timeout": 30,
|
||||
}
|
||||
executor = WaitForInputExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
# Should yield prompt first, then call request_info
|
||||
assert mock_context.yield_output.call_count == 1
|
||||
assert mock_context.yield_output.call_args_list[0][0][0] == "Please enter your name:"
|
||||
# request_info call for ExternalInputRequest
|
||||
mock_context.request_info.assert_called_once()
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.request_type == "user_input"
|
||||
|
||||
async def test_wait_for_input_executor_no_prompt(self, mock_context, mock_state):
|
||||
"""Test WaitForInputExecutor without prompt."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
WaitForInputExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "WaitForInput",
|
||||
"property": "Local.input",
|
||||
}
|
||||
executor = WaitForInputExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
# Should not yield output (no prompt), just call request_info
|
||||
assert mock_context.yield_output.call_count == 0
|
||||
mock_context.request_info.assert_called_once()
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.request_type == "user_input"
|
||||
|
||||
async def test_request_external_input_executor(self, mock_context, mock_state):
|
||||
"""Test RequestExternalInputExecutor."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
@@ -1786,8 +1590,8 @@ class TestHumanInputExecutorsCoverage:
|
||||
action_def = {
|
||||
"kind": "RequestExternalInput",
|
||||
"requestType": "approval",
|
||||
"message": "Please approve this request",
|
||||
"property": "Local.approvalResult",
|
||||
"prompt": {"text": "Please approve this request"},
|
||||
"variable": "Local.approvalResult",
|
||||
"timeout": 3600,
|
||||
"requiredFields": ["approver", "notes"],
|
||||
"metadata": {"priority": "high"},
|
||||
@@ -1817,8 +1621,8 @@ class TestHumanInputExecutorsCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Question",
|
||||
"question": "Select an option:",
|
||||
"property": "Local.selection",
|
||||
"question": {"text": "Select an option:"},
|
||||
"variable": "Local.selection",
|
||||
"choices": [
|
||||
{"value": "a", "label": "Option A"},
|
||||
{"value": "b"}, # No label, should use value
|
||||
@@ -1841,6 +1645,111 @@ class TestHumanInputExecutorsCoverage:
|
||||
assert choices[2] == {"value": "c", "label": "c"}
|
||||
assert request.metadata["allow_free_text"] is False
|
||||
|
||||
async def test_question_executor_reads_nested_question_text(self, mock_context, mock_state):
|
||||
"""QuestionExecutor reads ``question.text``/``variable``/``default`` into the request."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
QuestionExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "Question",
|
||||
"question": {"text": "What is your name?"},
|
||||
"variable": "Local.userName",
|
||||
"default": "Guest",
|
||||
}
|
||||
executor = QuestionExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
mock_context.request_info.assert_called_once()
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
# Canonical text comes through as a plain string, not the stringified dict.
|
||||
assert request.message == "What is your name?"
|
||||
# Canonical `variable` overrides the legacy default of Local.answer.
|
||||
assert request.metadata["output_property"] == "Local.userName"
|
||||
assert request.metadata["default_value"] == "Guest"
|
||||
|
||||
async def test_question_executor_reads_top_level_alternates(self, mock_context, mock_state):
|
||||
"""Top-level ``text``/``property``/``defaultValue`` are accepted as alternates."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
QuestionExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "Question",
|
||||
"text": "Legacy question",
|
||||
"property": "Local.legacyAnswer",
|
||||
"defaultValue": "legacy-default",
|
||||
}
|
||||
executor = QuestionExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.message == "Legacy question"
|
||||
assert request.metadata["output_property"] == "Local.legacyAnswer"
|
||||
assert request.metadata["default_value"] == "legacy-default"
|
||||
|
||||
async def test_request_external_input_reads_nested_prompt_text(self, mock_context, mock_state):
|
||||
"""RequestExternalInputExecutor reads ``prompt.text``/``variable``/``default``."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
RequestExternalInputExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "RequestExternalInput",
|
||||
"prompt": {"text": "Please approve"},
|
||||
"variable": "Local.approved",
|
||||
"default": "pending",
|
||||
}
|
||||
executor = RequestExternalInputExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.message == "Please approve"
|
||||
assert request.metadata["output_property"] == "Local.approved"
|
||||
assert request.metadata["default_value"] == "pending"
|
||||
|
||||
async def test_request_external_input_reads_top_level_alternates(self, mock_context, mock_state):
|
||||
"""Top-level ``message``/``property`` are accepted as alternates."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
RequestExternalInputExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "RequestExternalInput",
|
||||
"message": "Legacy message",
|
||||
"property": "Local.legacyApproval",
|
||||
}
|
||||
executor = RequestExternalInputExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.message == "Legacy message"
|
||||
assert request.metadata["output_property"] == "Local.legacyApproval"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional Agent Executor Tests - External Loop Coverage
|
||||
@@ -2122,7 +2031,7 @@ class TestBuilderControlFlowCreation:
|
||||
|
||||
# Create a mock loop_next executor
|
||||
loop_next = ForeachNextExecutor(
|
||||
{"kind": "Foreach", "itemsProperty": "items"},
|
||||
{"kind": "Foreach", "source": "=Local.items"},
|
||||
init_executor_id="foreach_init",
|
||||
id="foreach_next",
|
||||
)
|
||||
@@ -2181,7 +2090,7 @@ class TestBuilderControlFlowCreation:
|
||||
|
||||
# Create a mock loop_next executor
|
||||
loop_next = ForeachNextExecutor(
|
||||
{"kind": "Foreach", "itemsProperty": "items"},
|
||||
{"kind": "Foreach", "source": "=Local.items"},
|
||||
init_executor_id="foreach_init",
|
||||
id="foreach_next",
|
||||
)
|
||||
@@ -2235,8 +2144,8 @@ class TestBuilderEdgeWiring:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||
{"kind": "SendActivity", "id": "step_2", "activity": {"text": "two"}},
|
||||
@@ -2266,8 +2175,8 @@ class TestBuilderEdgeWiring:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||
{"kind": "BreakLoop", "id": "stop"},
|
||||
@@ -2292,8 +2201,8 @@ class TestBuilderEdgeWiring:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||
{
|
||||
@@ -2704,7 +2613,7 @@ class TestBuilderValidation:
|
||||
assert workflow is not None
|
||||
|
||||
def test_missing_required_field_foreach(self):
|
||||
"""Test Foreach without items raises error."""
|
||||
"""Test Foreach without source raises error."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
@@ -2717,7 +2626,7 @@ class TestBuilderValidation:
|
||||
builder.build()
|
||||
|
||||
assert "Foreach" in str(exc_info.value)
|
||||
assert "items" in str(exc_info.value)
|
||||
assert "source" in str(exc_info.value)
|
||||
|
||||
def test_self_referencing_goto_raises_error(self):
|
||||
"""Test that a goto referencing itself is detected."""
|
||||
@@ -2725,7 +2634,7 @@ class TestBuilderValidation:
|
||||
|
||||
yaml_def = {
|
||||
"name": "test_workflow",
|
||||
"actions": [{"id": "loop", "kind": "Goto", "target": "loop"}],
|
||||
"actions": [{"id": "loop", "kind": "GotoAction", "actionId": "loop"}],
|
||||
}
|
||||
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
@@ -2757,23 +2666,22 @@ class TestBuilderValidation:
|
||||
workflow = builder.build()
|
||||
assert workflow is not None
|
||||
|
||||
def test_validation_in_switch_branches(self):
|
||||
"""Test validation catches issues in Switch branches."""
|
||||
def test_validation_in_condition_group_branches(self):
|
||||
"""Test validation catches issues in ConditionGroup branches."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "test_workflow",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "Switch",
|
||||
"value": "=Local.choice",
|
||||
"cases": [
|
||||
"kind": "ConditionGroup",
|
||||
"conditions": [
|
||||
{
|
||||
"match": "a",
|
||||
"condition": '=Local.choice = "a"',
|
||||
"actions": [{"id": "dup", "kind": "SendActivity", "activity": {"text": "A"}}],
|
||||
},
|
||||
{
|
||||
"match": "b",
|
||||
"condition": '=Local.choice = "b"',
|
||||
"actions": [{"id": "dup", "kind": "SendActivity", "activity": {"text": "B"}}],
|
||||
},
|
||||
],
|
||||
@@ -2796,7 +2704,7 @@ class TestBuilderValidation:
|
||||
"actions": [
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"items": "=Local.items",
|
||||
"source": "=Local.items",
|
||||
"actions": [{"kind": "SendActivity"}], # Missing 'activity'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -207,16 +207,16 @@ class TestDeclarativeActionExecutor:
|
||||
# Note: ConditionEvaluatorExecutor tests removed - conditions are now evaluated on edges
|
||||
|
||||
@_requires_powerfx
|
||||
async def test_foreach_init_with_items(self, mock_context, mock_state):
|
||||
"""Test ForeachInitExecutor with items."""
|
||||
async def test_foreach_init_with_source(self, mock_context, mock_state):
|
||||
"""Test ForeachInitExecutor with the 'source' field."""
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
state.set("Local.items", ["a", "b", "c"])
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachInitExecutor(action_def)
|
||||
|
||||
@@ -240,8 +240,8 @@ class TestDeclarativeActionExecutor:
|
||||
# Use a literal empty list - no expression evaluation needed
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": [], # Direct empty list, not an expression
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": [], # Direct empty list, not an expression
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachInitExecutor(action_def)
|
||||
|
||||
@@ -264,7 +264,6 @@ class TestDeclarativeWorkflowBuilder:
|
||||
"SetValue",
|
||||
"SetVariable",
|
||||
"SendActivity",
|
||||
"EmitEvent",
|
||||
"EndWorkflow",
|
||||
"InvokeAzureAgent",
|
||||
"Question",
|
||||
@@ -335,8 +334,8 @@ class TestDeclarativeWorkflowBuilder:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "process_items",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "show_item", "activity": {"text": "=Local.item"}},
|
||||
],
|
||||
@@ -353,13 +352,13 @@ class TestDeclarativeWorkflowBuilder:
|
||||
assert "process_items_exit" in builder._executors
|
||||
assert "show_item" in builder._executors
|
||||
|
||||
def test_build_workflow_with_switch(self):
|
||||
"""Test building a workflow with Switch control flow."""
|
||||
def test_build_workflow_with_condition_group(self):
|
||||
"""Test building a workflow with ConditionGroup control flow."""
|
||||
yaml_def = {
|
||||
"name": "switch_workflow",
|
||||
"name": "condition_group_workflow",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "Switch",
|
||||
"kind": "ConditionGroup",
|
||||
"id": "check_status",
|
||||
"conditions": [
|
||||
{
|
||||
@@ -375,7 +374,7 @@ class TestDeclarativeWorkflowBuilder:
|
||||
],
|
||||
},
|
||||
],
|
||||
"else": [
|
||||
"elseActions": [
|
||||
{"kind": "SendActivity", "id": "say_unknown", "activity": {"text": "Unknown"}},
|
||||
],
|
||||
},
|
||||
@@ -385,12 +384,12 @@ class TestDeclarativeWorkflowBuilder:
|
||||
workflow = builder.build()
|
||||
|
||||
assert workflow is not None
|
||||
# Verify switch executors were created
|
||||
# Verify ConditionGroup branch executors were created
|
||||
# Note: No join executors - branches wire directly to successor
|
||||
assert "say_active" in builder._executors
|
||||
assert "say_pending" in builder._executors
|
||||
assert "say_unknown" in builder._executors
|
||||
# Entry node is created when Switch is first action
|
||||
# Entry node is created when ConditionGroup is first action
|
||||
assert "_workflow_entry" in builder._executors
|
||||
|
||||
|
||||
@@ -493,9 +492,9 @@ class TestHumanInputExecutors:
|
||||
|
||||
action_def = {
|
||||
"kind": "Question",
|
||||
"text": "What is your name?",
|
||||
"property": "Local.name",
|
||||
"defaultValue": "Anonymous",
|
||||
"question": {"text": "What is your name?"},
|
||||
"variable": "Local.name",
|
||||
"default": "Anonymous",
|
||||
}
|
||||
executor = QuestionExecutor(action_def)
|
||||
|
||||
@@ -509,36 +508,6 @@ class TestHumanInputExecutors:
|
||||
assert request.request_type == "question"
|
||||
assert "What is your name?" in request.message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirmation_executor(self, mock_context, mock_state):
|
||||
"""Test ConfirmationExecutor."""
|
||||
from agent_framework_declarative._workflows import (
|
||||
ConfirmationExecutor,
|
||||
ExternalInputRequest,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "Confirmation",
|
||||
"text": "Do you want to continue?",
|
||||
"property": "Local.confirmed",
|
||||
"yesLabel": "Yes, continue",
|
||||
"noLabel": "No, stop",
|
||||
}
|
||||
executor = ConfirmationExecutor(action_def)
|
||||
|
||||
# Execute
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
# Verify request_info was called with ExternalInputRequest
|
||||
mock_context.request_info.assert_called_once()
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.request_type == "confirmation"
|
||||
assert "continue" in request.message.lower()
|
||||
|
||||
|
||||
@_requires_powerfx
|
||||
class TestParseValueExecutor:
|
||||
|
||||
@@ -100,8 +100,8 @@ class TestGraphBasedWorkflowExecution:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "process_items",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "show_item", "activity": {"text": "=Local.item"}},
|
||||
],
|
||||
@@ -131,8 +131,8 @@ class TestGraphBasedWorkflowExecution:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": '="1-" & Local.item'}},
|
||||
{"kind": "SendActivity", "id": "step_2", "activity": {"text": '="2-" & Local.item'}},
|
||||
@@ -151,14 +151,14 @@ class TestGraphBasedWorkflowExecution:
|
||||
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."""
|
||||
async def test_workflow_with_condition_group(self):
|
||||
"""Test workflow with ConditionGroup."""
|
||||
yaml_def = {
|
||||
"name": "switch_workflow",
|
||||
"name": "condition_group_workflow",
|
||||
"actions": [
|
||||
{"kind": "SetValue", "id": "set_level", "path": "Local.level", "value": 2},
|
||||
{
|
||||
"kind": "Switch",
|
||||
"kind": "ConditionGroup",
|
||||
"id": "check_level",
|
||||
"conditions": [
|
||||
{
|
||||
@@ -174,7 +174,7 @@ class TestGraphBasedWorkflowExecution:
|
||||
],
|
||||
},
|
||||
],
|
||||
"else": [
|
||||
"elseActions": [
|
||||
{"kind": "SendActivity", "id": "default", "activity": {"text": "Other level"}},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -122,14 +122,16 @@ actions:
|
||||
- cherry
|
||||
itemName: fruit
|
||||
actions:
|
||||
- kind: AppendValue
|
||||
path: Local.fruits
|
||||
value: processed
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: processed
|
||||
""")
|
||||
|
||||
_result = await workflow.run({}) # noqa: F841
|
||||
# The foreach should have processed 3 items
|
||||
# We can check this by examining the workflow outputs
|
||||
result = await workflow.run({})
|
||||
outputs = result.get_outputs()
|
||||
# The foreach should have processed 3 items, emitting "processed" each time.
|
||||
processed_outputs = [o for o in outputs if "processed" in str(o)]
|
||||
assert len(processed_outputs) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_if_workflow(self):
|
||||
@@ -556,28 +558,27 @@ actions:
|
||||
|
||||
|
||||
@_requires_powerfx
|
||||
class TestWorkflowFactorySwitch:
|
||||
"""Tests for Switch/Case action."""
|
||||
class TestWorkflowFactoryConditionGroup:
|
||||
"""Tests for ConditionGroup action."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_with_matching_case(self):
|
||||
"""Test Switch with a matching case."""
|
||||
async def test_condition_group_with_matching_condition(self):
|
||||
"""Test ConditionGroup with a matching condition."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: switch-test
|
||||
name: condition-group-test
|
||||
actions:
|
||||
- kind: SetValue
|
||||
path: Local.color
|
||||
value: red
|
||||
- kind: Switch
|
||||
value: =Local.color
|
||||
cases:
|
||||
- match: red
|
||||
- kind: ConditionGroup
|
||||
conditions:
|
||||
- condition: =Local.color = "red"
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Color is red
|
||||
- match: blue
|
||||
- condition: =Local.color = "blue"
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
@@ -590,29 +591,28 @@ actions:
|
||||
assert any("Color is red" in str(o) for o in outputs)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_with_default(self):
|
||||
"""Test Switch falling through to default."""
|
||||
async def test_condition_group_with_else_actions(self):
|
||||
"""Test ConditionGroup falling through to elseActions when no condition matches."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: switch-default-test
|
||||
name: condition-group-else-test
|
||||
actions:
|
||||
- kind: SetValue
|
||||
path: Local.color
|
||||
value: green
|
||||
- kind: Switch
|
||||
value: =Local.color
|
||||
cases:
|
||||
- match: red
|
||||
- kind: ConditionGroup
|
||||
conditions:
|
||||
- condition: =Local.color = "red"
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Red
|
||||
- match: blue
|
||||
- condition: =Local.color = "blue"
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Blue
|
||||
default:
|
||||
elseActions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Unknown color
|
||||
@@ -653,54 +653,273 @@ actions:
|
||||
|
||||
assert any("Done" in str(o) for o in outputs)
|
||||
|
||||
|
||||
class TestRenamedAliasKindsAreUnknown:
|
||||
"""Tests that the previously-accepted ``Switch``/``Goto`` kind names are now unknown.
|
||||
|
||||
YAML that still names one of these kinds falls through the existing
|
||||
unknown-kind warning path (the action is silently skipped) instead
|
||||
of being routed to ``ConditionGroup``/``GotoAction``.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_value(self):
|
||||
"""Test AppendValue action."""
|
||||
async def test_switch_kind_is_unknown(self, caplog):
|
||||
"""A workflow whose YAML uses kind: Switch logs an unknown-kind warning."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: append-test
|
||||
with caplog.at_level(
|
||||
"WARNING",
|
||||
logger="agent_framework_declarative._workflows._declarative_builder",
|
||||
):
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: switch-alias-removed
|
||||
actions:
|
||||
- kind: SetValue
|
||||
path: Local.list
|
||||
value: []
|
||||
- kind: AppendValue
|
||||
path: Local.list
|
||||
value: first
|
||||
- kind: AppendValue
|
||||
path: Local.list
|
||||
value: second
|
||||
- kind: Switch
|
||||
value: =Local.color
|
||||
cases:
|
||||
- match: red
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Color is red
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Done
|
||||
""")
|
||||
result = await workflow.run({})
|
||||
|
||||
result = await workflow.run({})
|
||||
# Switch is no longer a recognised kind -> warning emitted + action skipped.
|
||||
assert any("Unknown action kind 'Switch'" in record.getMessage() for record in caplog.records)
|
||||
# The trailing SendActivity still runs so the workflow completes successfully.
|
||||
outputs = result.get_outputs()
|
||||
|
||||
assert any("Done" in str(o) for o in outputs)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_event(self):
|
||||
"""Test EmitEvent action."""
|
||||
async def test_goto_kind_is_unknown(self, caplog):
|
||||
"""A workflow whose YAML uses kind: Goto logs an unknown-kind warning."""
|
||||
factory = WorkflowFactory()
|
||||
with caplog.at_level(
|
||||
"WARNING",
|
||||
logger="agent_framework_declarative._workflows._declarative_builder",
|
||||
):
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: goto-alias-removed
|
||||
actions:
|
||||
- id: target
|
||||
kind: SendActivity
|
||||
activity:
|
||||
text: Arrived
|
||||
- kind: Goto
|
||||
target: target
|
||||
""")
|
||||
result = await workflow.run({})
|
||||
|
||||
# Goto is no longer a recognised kind -> warning emitted + action skipped.
|
||||
assert any("Unknown action kind 'Goto'" in record.getMessage() for record in caplog.records)
|
||||
# The first SendActivity still emits its output.
|
||||
outputs = result.get_outputs()
|
||||
assert any("Arrived" in str(o) for o in outputs)
|
||||
|
||||
|
||||
class TestDroppedShapesAreRejected:
|
||||
"""Tests that previously-accepted alternate YAML shapes are now rejected at validation.
|
||||
|
||||
``ConditionGroup`` no longer accepts the ``value``/``cases`` shape and
|
||||
``Foreach`` no longer accepts the ``items`` field. Both kinds raise a
|
||||
``ValueError`` from the builder when the required field is missing.
|
||||
"""
|
||||
|
||||
def test_condition_group_with_cases_raises(self):
|
||||
"""ConditionGroup using value/cases (no conditions) must fail validation."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "cg-cases-rejected",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "ConditionGroup",
|
||||
"value": "=Local.color",
|
||||
"cases": [
|
||||
{"match": "red", "actions": [{"kind": "SendActivity", "activity": {"text": "Red"}}]},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
with pytest.raises(ValueError, match="conditions"):
|
||||
builder.build()
|
||||
|
||||
def test_foreach_with_items_raises(self):
|
||||
"""Foreach using items (no source) must fail validation."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "fe-items-rejected",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"items": "=Local.list",
|
||||
"actions": [{"kind": "SendActivity", "activity": {"text": "hi"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
with pytest.raises(ValueError, match="source"):
|
||||
builder.build()
|
||||
|
||||
def test_condition_group_with_else_field_raises(self):
|
||||
"""ConditionGroup with an ``else`` field must fail fast and point at ``elseActions``."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "cg-else-rejected",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "ConditionGroup",
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "=Local.x = 1",
|
||||
"actions": [{"kind": "SendActivity", "activity": {"text": "one"}}],
|
||||
},
|
||||
],
|
||||
"else": [{"kind": "SendActivity", "activity": {"text": "other"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
with pytest.raises(ValueError, match="elseActions"):
|
||||
builder.build()
|
||||
|
||||
def test_condition_group_with_default_field_raises(self):
|
||||
"""ConditionGroup with a ``default`` field must fail fast and point at ``elseActions``."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "cg-default-rejected",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "ConditionGroup",
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "=Local.x = 1",
|
||||
"actions": [{"kind": "SendActivity", "activity": {"text": "one"}}],
|
||||
},
|
||||
],
|
||||
"default": [{"kind": "SendActivity", "activity": {"text": "other"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
with pytest.raises(ValueError, match="elseActions"):
|
||||
builder.build()
|
||||
|
||||
|
||||
class TestQuestionAndRequestExternalInputShapes:
|
||||
"""Tests for accepted YAML shapes of ``Question`` and ``RequestExternalInput``.
|
||||
|
||||
Both kinds accept either a nested ``{question|prompt: {text: ...}}`` form
|
||||
or a top-level alternate (``text``/``message``) for the prompt content,
|
||||
and either ``variable`` or top-level ``property`` for the destination path.
|
||||
Missing both spellings of a required field raises during validation.
|
||||
"""
|
||||
|
||||
def test_question_nested_question_text_builds(self):
|
||||
"""A workflow whose Question uses nested ``question.text`` builds without error."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: emit-event-test
|
||||
name: question-nested
|
||||
actions:
|
||||
- kind: EmitEvent
|
||||
event:
|
||||
name: test_event
|
||||
data:
|
||||
message: Hello
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Event emitted
|
||||
- kind: Question
|
||||
question:
|
||||
text: "What is your name?"
|
||||
variable: Local.userName
|
||||
default: "Guest"
|
||||
""")
|
||||
assert workflow is not None
|
||||
|
||||
def test_request_external_input_nested_prompt_text_builds(self):
|
||||
"""A workflow whose RequestExternalInput uses nested ``prompt.text`` builds without error."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: rei-nested
|
||||
actions:
|
||||
- kind: RequestExternalInput
|
||||
prompt:
|
||||
text: "Please approve"
|
||||
variable: Local.approved
|
||||
default: pending
|
||||
""")
|
||||
assert workflow is not None
|
||||
|
||||
def test_question_missing_question_raises(self):
|
||||
"""A Question action missing both `question` and the `text` alternate must fail validation."""
|
||||
factory = WorkflowFactory()
|
||||
with pytest.raises((ValueError, DeclarativeWorkflowError), match="question"):
|
||||
factory.create_workflow_from_yaml("""
|
||||
name: question-missing-question
|
||||
actions:
|
||||
- kind: Question
|
||||
variable: Local.x
|
||||
""")
|
||||
|
||||
result = await workflow.run({})
|
||||
outputs = result.get_outputs()
|
||||
def test_question_missing_variable_raises(self):
|
||||
"""A Question action missing both `variable` and the `property` alternate must fail validation."""
|
||||
factory = WorkflowFactory()
|
||||
with pytest.raises((ValueError, DeclarativeWorkflowError), match="variable"):
|
||||
factory.create_workflow_from_yaml("""
|
||||
name: question-missing-variable
|
||||
actions:
|
||||
- kind: Question
|
||||
question:
|
||||
text: "Hi"
|
||||
""")
|
||||
|
||||
# Workflow should complete
|
||||
assert any("Event emitted" in str(o) for o in outputs)
|
||||
def test_request_external_input_missing_prompt_raises(self):
|
||||
"""RequestExternalInput missing both `prompt` and the `message` alternate must fail validation."""
|
||||
factory = WorkflowFactory()
|
||||
with pytest.raises((ValueError, DeclarativeWorkflowError), match="prompt"):
|
||||
factory.create_workflow_from_yaml("""
|
||||
name: rei-missing-prompt
|
||||
actions:
|
||||
- kind: RequestExternalInput
|
||||
variable: Local.x
|
||||
""")
|
||||
|
||||
def test_request_external_input_missing_variable_raises(self):
|
||||
"""RequestExternalInput missing both `variable` and the `property` alternate must fail validation."""
|
||||
factory = WorkflowFactory()
|
||||
with pytest.raises((ValueError, DeclarativeWorkflowError), match="variable"):
|
||||
factory.create_workflow_from_yaml("""
|
||||
name: rei-missing-variable
|
||||
actions:
|
||||
- kind: RequestExternalInput
|
||||
prompt:
|
||||
text: "Hi"
|
||||
""")
|
||||
|
||||
def test_question_top_level_field_names_accepted(self):
|
||||
"""Top-level ``text`` + ``property`` + ``defaultValue`` are accepted on Question."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: question-legacy
|
||||
actions:
|
||||
- kind: Question
|
||||
text: "What is your name?"
|
||||
property: Local.userName
|
||||
defaultValue: "Guest"
|
||||
""")
|
||||
assert workflow is not None
|
||||
|
||||
def test_request_external_input_top_level_field_names_accepted(self):
|
||||
"""Top-level ``message`` + ``property`` are accepted on RequestExternalInput."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: rei-legacy
|
||||
actions:
|
||||
- kind: RequestExternalInput
|
||||
message: "Please approve"
|
||||
property: Local.approved
|
||||
""")
|
||||
assert workflow is not None
|
||||
|
||||
|
||||
class TestWorkflowFactoryYamlErrors:
|
||||
|
||||
@@ -227,7 +227,6 @@ class TestHandlerCoverage:
|
||||
"OnConversationStart", # Trigger kind, not an action
|
||||
"ConditionGroup", # Decomposed into evaluator/join nodes
|
||||
"GotoAction", # Resolved as graph edges, not executor nodes
|
||||
"Goto", # Alias for GotoAction
|
||||
}
|
||||
|
||||
missing_executors = all_action_kinds - registered_executors - structural_kinds
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
"""Tests for cleanup hook registration and execution."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -123,7 +123,7 @@ async def test_register_cleanup_multiple_hooks():
|
||||
|
||||
# Execute all hooks
|
||||
for hook in hooks:
|
||||
if asyncio.iscoroutinefunction(hook):
|
||||
if inspect.iscoroutinefunction(hook):
|
||||
await hook()
|
||||
else:
|
||||
hook()
|
||||
|
||||
@@ -610,6 +610,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
project_client: AIProjectClient | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
middleware: Sequence[MiddlewareTypes] | None = None,
|
||||
@@ -639,6 +640,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
credential: Azure credential for authentication.
|
||||
project_client: An existing AIProjectClient to use.
|
||||
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
|
||||
default_headers: Additional HTTP headers for requests made through the OpenAI client.
|
||||
tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted.
|
||||
context_providers: Optional context providers for injecting dynamic context.
|
||||
middleware: Optional agent-level middleware.
|
||||
@@ -672,6 +674,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
"credential": credential,
|
||||
"project_client": project_client,
|
||||
"allow_preview": allow_preview,
|
||||
"default_headers": default_headers,
|
||||
"env_file_path": env_file_path,
|
||||
"env_file_encoding": env_file_encoding,
|
||||
}
|
||||
@@ -894,6 +897,7 @@ class FoundryAgent( # type: ignore[misc]
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
project_client: AIProjectClient | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
middleware: Sequence[MiddlewareTypes] | None = None,
|
||||
@@ -936,6 +940,7 @@ class FoundryAgent( # type: ignore[misc]
|
||||
Set this to ``True`` for HostedAgents that need preview-only
|
||||
session APIs, including lazy service session creation from
|
||||
``isolation_key``.
|
||||
default_headers: Additional HTTP headers for requests made through the OpenAI client.
|
||||
tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted.
|
||||
context_providers: Optional context providers.
|
||||
middleware: Optional agent-level middleware.
|
||||
@@ -963,6 +968,7 @@ class FoundryAgent( # type: ignore[misc]
|
||||
credential=credential,
|
||||
project_client=project_client,
|
||||
allow_preview=allow_preview,
|
||||
default_headers=default_headers,
|
||||
tools=tools,
|
||||
context_providers=context_providers,
|
||||
middleware=middleware,
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-openai>=1.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"agent-framework-openai>=1.7.0,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
]
|
||||
|
||||
@@ -505,6 +505,40 @@ def test_raw_foundry_agent_init_creates_client() -> None:
|
||||
assert agent.client.agent_name == "test-agent"
|
||||
|
||||
|
||||
def test_raw_foundry_agent_init_passes_default_headers_to_client() -> None:
|
||||
"""Test that RawFoundryAgent passes default_headers to the underlying client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
default_headers = {"x-ms-user-isolation-key": "user-1"}
|
||||
|
||||
RawFoundryAgent(
|
||||
project_client=mock_project,
|
||||
agent_name="hosted-agent",
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
mock_project.get_openai_client.assert_called_once()
|
||||
assert mock_project.get_openai_client.call_args.kwargs["default_headers"] == default_headers
|
||||
|
||||
|
||||
def test_foundry_agent_init_passes_default_headers_to_client() -> None:
|
||||
"""Test that FoundryAgent passes default_headers to the underlying client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
default_headers = {"x-ms-user-isolation-key": "user-1"}
|
||||
|
||||
FoundryAgent(
|
||||
project_client=mock_project,
|
||||
agent_name="hosted-agent",
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
mock_project.get_openai_client.assert_called_once()
|
||||
assert mock_project.get_openai_client.call_args.kwargs["default_headers"] == default_headers
|
||||
|
||||
|
||||
def test_raw_foundry_agent_init_with_custom_client_type() -> None:
|
||||
"""Test that client_type parameter is respected."""
|
||||
|
||||
@@ -523,6 +557,7 @@ def test_raw_foundry_agent_init_with_custom_client_type() -> None:
|
||||
def test_raw_foundry_agent_init_uses_explicit_parameters() -> None:
|
||||
signature = inspect.signature(RawFoundryAgent.__init__)
|
||||
|
||||
assert "default_headers" in signature.parameters
|
||||
assert "instructions" in signature.parameters
|
||||
assert "default_options" in signature.parameters
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
@@ -534,6 +569,7 @@ def test_raw_foundry_agent_init_uses_explicit_parameters() -> None:
|
||||
def test_foundry_agent_init_uses_explicit_parameters() -> None:
|
||||
signature = inspect.signature(FoundryAgent.__init__)
|
||||
|
||||
assert "default_headers" in signature.parameters
|
||||
assert "instructions" in signature.parameters
|
||||
assert "default_options" in signature.parameters
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
|
||||
@@ -72,6 +72,7 @@ from azure.ai.agentserver.responses.models import (
|
||||
MessageContentOutputTextContent,
|
||||
MessageContentReasoningTextContent,
|
||||
MessageContentRefusalContent,
|
||||
MessageRole,
|
||||
OAuthConsentRequestOutputItem,
|
||||
OutputItem,
|
||||
OutputItemApplyPatchToolCall,
|
||||
@@ -116,6 +117,8 @@ from typing_extensions import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE = f"{MessageRole.__module__}:{MessageRole.__qualname__}"
|
||||
|
||||
|
||||
# region Approval Storage
|
||||
class ApprovalStorage(Protocol):
|
||||
@@ -249,7 +252,12 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
|
||||
storage_path = (root_path / context_id).resolve()
|
||||
if not storage_path.is_relative_to(root_path):
|
||||
raise RuntimeError(f"Invalid checkpoint context id: {context_id!r}")
|
||||
return FileCheckpointStorage(storage_path)
|
||||
return FileCheckpointStorage(
|
||||
storage_path,
|
||||
# Keep this provider-specific allowlist narrow. Hosted workflow
|
||||
# checkpoints can persist Azure's role enum inside Message objects.
|
||||
allowed_checkpoint_types=[_AZURE_RESPONSES_MESSAGE_ROLE_TYPE],
|
||||
)
|
||||
|
||||
|
||||
# endregion Approval Storage
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260521"
|
||||
version = "1.0.0a260528"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b5,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
|
||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -26,6 +26,9 @@ from agent_framework import (
|
||||
Message,
|
||||
RawAgent,
|
||||
ResponseStream,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowMessage,
|
||||
)
|
||||
from azure.ai.agentserver.responses import InMemoryResponseProvider
|
||||
from mcp import McpError
|
||||
@@ -34,6 +37,7 @@ from typing_extensions import Any
|
||||
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from agent_framework_foundry_hosting._responses import (
|
||||
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE, # pyright: ignore[reportPrivateUsage]
|
||||
CONSENT_ERROR_CODE,
|
||||
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
@@ -2712,6 +2716,23 @@ class TestCheckpointContextPathValidation:
|
||||
|
||||
return _checkpoint_storage_for_context
|
||||
|
||||
@staticmethod
|
||||
def _checkpoint_with_azure_message_role() -> WorkflowCheckpoint:
|
||||
from azure.ai.agentserver.responses.models import MessageRole
|
||||
|
||||
return WorkflowCheckpoint(
|
||||
workflow_name="wf",
|
||||
graph_signature_hash="hash",
|
||||
messages={
|
||||
"executor": [
|
||||
WorkflowMessage(
|
||||
data=Message(role=MessageRole.USER, contents=[Content.from_text("hello")]),
|
||||
source_id="source",
|
||||
)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def test_valid_segment_creates_storage_under_root(self, tmp_path: Any) -> None:
|
||||
helper = self._helper()
|
||||
root = tmp_path / "root"
|
||||
@@ -2720,6 +2741,124 @@ class TestCheckpointContextPathValidation:
|
||||
assert storage.storage_path.is_dir()
|
||||
assert storage.storage_path.parent == root.resolve()
|
||||
|
||||
def test_azure_message_role_allowlist_type_matches_generated_sdk_path(self) -> None:
|
||||
assert (
|
||||
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE
|
||||
== "azure.ai.agentserver.responses.models._generated.sdk.models.models._enums:MessageRole"
|
||||
)
|
||||
|
||||
async def test_storage_allows_azure_message_role_checkpoint_restore(self, tmp_path: Any) -> None:
|
||||
from azure.ai.agentserver.responses.models import MessageRole
|
||||
|
||||
helper = self._helper()
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
storage = helper(str(root), "resp_abc123")
|
||||
checkpoint = self._checkpoint_with_azure_message_role()
|
||||
|
||||
await storage.save(checkpoint)
|
||||
loaded = await storage.load(checkpoint.checkpoint_id)
|
||||
|
||||
loaded_message = loaded.messages["executor"][0].data
|
||||
assert isinstance(loaded_message, Message)
|
||||
assert type(loaded_message.role) is MessageRole
|
||||
assert loaded_message.role == MessageRole.USER
|
||||
assert loaded_message.text == "hello"
|
||||
|
||||
async def test_plain_storage_blocks_azure_message_role_checkpoint_restore(self, tmp_path: Any) -> None:
|
||||
storage = FileCheckpointStorage(tmp_path / "plain")
|
||||
checkpoint = self._checkpoint_with_azure_message_role()
|
||||
|
||||
await storage.save(checkpoint)
|
||||
with pytest.raises(WorkflowCheckpointException, match="MessageRole"):
|
||||
await storage.load(checkpoint.checkpoint_id)
|
||||
|
||||
async def test_get_latest_restores_azure_message_role(self, tmp_path: Any) -> None:
|
||||
from azure.ai.agentserver.responses.models import MessageRole
|
||||
|
||||
helper = self._helper()
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
storage = helper(str(root), "resp_abc123")
|
||||
checkpoint = self._checkpoint_with_azure_message_role()
|
||||
|
||||
await storage.save(checkpoint)
|
||||
latest = await storage.get_latest(workflow_name="wf")
|
||||
|
||||
assert latest is not None
|
||||
assert latest.checkpoint_id == checkpoint.checkpoint_id
|
||||
latest_message = latest.messages["executor"][0].data
|
||||
assert isinstance(latest_message, Message)
|
||||
assert type(latest_message.role) is MessageRole
|
||||
|
||||
async def test_get_latest_silently_skips_without_allowlist(
|
||||
self, tmp_path: Any, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
import logging
|
||||
|
||||
storage = FileCheckpointStorage(tmp_path / "plain")
|
||||
checkpoint = self._checkpoint_with_azure_message_role()
|
||||
|
||||
await storage.save(checkpoint)
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework"):
|
||||
latest = await storage.get_latest(workflow_name="wf")
|
||||
|
||||
assert latest is None
|
||||
assert any("MessageRole" in message for message in caplog.messages)
|
||||
|
||||
async def test_handle_inner_workflow_restores_message_role_checkpoint_from_previous_response(
|
||||
self, tmp_path: Any
|
||||
) -> None:
|
||||
from agent_framework import WorkflowAgent
|
||||
from azure.ai.agentserver.responses import ResponseContext
|
||||
from azure.ai.agentserver.responses.models import CreateResponse, ItemMessage
|
||||
|
||||
previous_response_id = "resp_previous"
|
||||
response_id = "resp_current"
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
checkpoint_storage = self._helper()(str(root), previous_response_id)
|
||||
checkpoint = self._checkpoint_with_azure_message_role()
|
||||
await checkpoint_storage.save(checkpoint)
|
||||
|
||||
agent = MagicMock(spec=WorkflowAgent)
|
||||
agent.id = "wf-agent"
|
||||
agent.name = "wf"
|
||||
agent.description = ""
|
||||
agent.context_providers = []
|
||||
agent.workflow = MagicMock()
|
||||
agent.workflow.name = "wf"
|
||||
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
|
||||
agent.run = AsyncMock(
|
||||
side_effect=[
|
||||
AgentResponse(messages=[]),
|
||||
AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]),
|
||||
]
|
||||
)
|
||||
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())
|
||||
server._checkpoint_storage_path = str(root) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
request = CreateResponse(model="m", input="hi", previous_response_id=previous_response_id)
|
||||
context = ResponseContext(
|
||||
response_id=response_id, previous_response_id=previous_response_id, mode_flags=MagicMock()
|
||||
)
|
||||
input_item = ItemMessage({"type": "message", "role": "user", "content": "next turn"})
|
||||
|
||||
with patch.object(ResponseContext, "get_input_items", new=AsyncMock(return_value=[input_item])):
|
||||
async for _ in server._handle_inner_workflow(request, context): # pyright: ignore[reportPrivateUsage]
|
||||
pass
|
||||
|
||||
assert agent.run.call_count == 2
|
||||
restore_call = agent.run.call_args_list[0]
|
||||
assert restore_call.kwargs["checkpoint_id"] == checkpoint.checkpoint_id
|
||||
assert restore_call.kwargs["checkpoint_storage"].storage_path == (root / previous_response_id).resolve()
|
||||
|
||||
new_turn_call = agent.run.call_args_list[1]
|
||||
new_turn_messages = new_turn_call.args[0]
|
||||
assert len(new_turn_messages) == 1
|
||||
assert new_turn_messages[0].text == "next turn"
|
||||
assert new_turn_call.kwargs["checkpoint_storage"].storage_path == (root / response_id).resolve()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_id",
|
||||
[
|
||||
|
||||
@@ -636,7 +636,11 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
continuation_token["response_id"],
|
||||
stream=True,
|
||||
)
|
||||
served_model = self._extract_served_model(raw_stream_response.headers)
|
||||
# Read headers defensively: telemetry instrumentors (e.g. azure-ai-projects
|
||||
# experimental tracing) wrap the streaming response in objects that do not
|
||||
# proxy ``.headers``. Degrade gracefully so the served-model surfacing is
|
||||
# best-effort instead of crashing the whole call.
|
||||
served_model = self._extract_served_model(getattr(raw_stream_response, "headers", None))
|
||||
async with raw_stream_response.parse() as stream_response:
|
||||
async for chunk in stream_response:
|
||||
update = self._parse_chunk_from_openai(
|
||||
@@ -677,7 +681,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
raw_create_response = await client.responses.with_raw_response.create(
|
||||
stream=True, **run_options
|
||||
)
|
||||
served_model = self._extract_served_model(raw_create_response.headers)
|
||||
# See note above on ``raw_stream_response.headers``.
|
||||
served_model = self._extract_served_model(getattr(raw_create_response, "headers", None))
|
||||
async with raw_create_response.parse() as stream_response:
|
||||
async for chunk in stream_response:
|
||||
update = self._parse_chunk_from_openai(
|
||||
@@ -706,7 +711,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
except Exception as ex:
|
||||
self._handle_request_error(ex)
|
||||
chat_response = self._parse_response_from_openai(response, options=validated_options)
|
||||
served_model = self._extract_served_model(raw_response.headers)
|
||||
# See note above on ``raw_stream_response.headers``.
|
||||
served_model = self._extract_served_model(getattr(raw_response, "headers", None))
|
||||
if served_model is not None:
|
||||
chat_response.model = served_model
|
||||
# Once the background response completes, drop the continuation_token from
|
||||
@@ -728,7 +734,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
except Exception as ex:
|
||||
self._handle_request_error(ex)
|
||||
chat_response = self._parse_response_from_openai(response, options=validated_options)
|
||||
served_model = self._extract_served_model(raw_response.headers)
|
||||
# See note above on ``raw_stream_response.headers``.
|
||||
served_model = self._extract_served_model(getattr(raw_response, "headers", None))
|
||||
if served_model is not None:
|
||||
chat_response.model = served_model
|
||||
return chat_response
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -841,6 +841,88 @@ async def test_served_model_header_not_captured_for_streaming_text_format() -> N
|
||||
assert update.model == "test-model"
|
||||
|
||||
|
||||
async def test_streaming_response_without_headers_attribute_does_not_crash() -> None:
|
||||
"""Regression for #6028.
|
||||
|
||||
Some telemetry instrumentors (e.g. ``azure-ai-projects`` experimental GenAI tracing,
|
||||
activated by ``AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true``) monkey-patch
|
||||
``openai.resources.responses.AsyncResponses.create`` at the class level and return
|
||||
an ``AsyncStreamWrapper`` whose class genuinely has no ``headers`` attribute. The
|
||||
``with_raw_response.create`` wrapper does not re-wrap the return value
|
||||
(``async_to_raw_response_wrapper`` only injects an extra header into the request),
|
||||
so ``raw_create_response`` in ``_inner_get_response`` ends up being the wrapper
|
||||
itself. Reading ``raw_create_response.headers`` used to raise ``AttributeError``
|
||||
and bubble up as ``ChatClientException``, breaking every streaming call. The
|
||||
defensive ``getattr(..., "headers", None)`` should now degrade gracefully:
|
||||
no served-model surfacing, but the stream still completes.
|
||||
"""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
events = [
|
||||
ResponseTextDeltaEvent(
|
||||
type="response.output_text.delta",
|
||||
content_index=0,
|
||||
item_id="text_item",
|
||||
output_index=0,
|
||||
sequence_number=1,
|
||||
logprobs=[],
|
||||
delta="Hello",
|
||||
),
|
||||
]
|
||||
|
||||
class _StreamWrapperWithoutHeaders:
|
||||
"""Mimics ``azure.ai.projects.telemetry._responses_instrumentor.AsyncStreamWrapper``:
|
||||
an async iterator that proxies the stream contents but does not expose ``.headers``.
|
||||
``hasattr(wrapper, "headers")`` returns ``False`` so ``getattr(..., "headers", None)``
|
||||
falls through to the default — matching the real instrumentor's class layout.
|
||||
"""
|
||||
|
||||
def __init__(self, events: list[object]) -> None:
|
||||
self._events = events
|
||||
self._iterator = iter(())
|
||||
|
||||
def __aiter__(self) -> "_StreamWrapperWithoutHeaders":
|
||||
self._iterator = iter(self._events)
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> object:
|
||||
try:
|
||||
return next(self._iterator)
|
||||
except StopIteration as exc:
|
||||
raise StopAsyncIteration from exc
|
||||
|
||||
def parse(self) -> "_StreamWrapperWithoutHeaders":
|
||||
return self
|
||||
|
||||
async def __aenter__(self) -> "_StreamWrapperWithoutHeaders":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
traceback: object | None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
headerless_stream = _StreamWrapperWithoutHeaders(events)
|
||||
# Sanity-check the simulation: the real instrumentor's wrapper genuinely lacks ``.headers``.
|
||||
assert not hasattr(headerless_stream, "headers")
|
||||
|
||||
with (
|
||||
patch.object(client, "_prepare_request", new=AsyncMock(return_value=(client.client, {}, {}))),
|
||||
patch.object(client.client.responses, "create", new=AsyncMock(return_value=headerless_stream)),
|
||||
patch.object(client, "_get_metadata_from_response", return_value={}),
|
||||
):
|
||||
stream = client._inner_get_response(messages=[Message(role="user", contents=["Hi"])], options={}, stream=True)
|
||||
updates = [update async for update in stream]
|
||||
|
||||
assert updates, "Expected the stream to complete even when the wrapper lacks .headers"
|
||||
for update in updates:
|
||||
# No header => no override => model stays the deployment alias.
|
||||
assert update.model == "test-model"
|
||||
|
||||
|
||||
async def test_streaming_text_format_preserves_final_structured_output() -> None:
|
||||
"""Streaming structured output should still parse into the final ChatResponse value."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core[all]==1.6.0",
|
||||
"agent-framework-core[all]==1.7.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -51,19 +51,16 @@ actions:
|
||||
### Variable Actions
|
||||
- `SetValue` - Set a variable in state
|
||||
- `SetVariable` - Set a variable (.NET style naming)
|
||||
- `AppendValue` - Append to a list
|
||||
- `ResetVariable` - Clear a variable
|
||||
|
||||
### Control Flow
|
||||
- `If` - Conditional branching
|
||||
- `Switch` - Multi-way branching
|
||||
- `ConditionGroup` - Multi-way branching
|
||||
- `Foreach` - Iterate over collections
|
||||
- `RepeatUntil` - Loop until condition
|
||||
- `GotoAction` - Jump to labeled action
|
||||
|
||||
### Output
|
||||
- `SendActivity` - Send text/attachments to user
|
||||
- `EmitEvent` - Emit custom events
|
||||
|
||||
### Agent Invocation
|
||||
- `InvokeAzureAgent` - Call an Azure AI agent
|
||||
@@ -74,4 +71,4 @@ actions:
|
||||
|
||||
### Human-in-Loop
|
||||
- `Question` - Request user input
|
||||
- `WaitForInput` - Pause for external input
|
||||
- `RequestExternalInput` - Request external data/approval
|
||||
|
||||
@@ -27,6 +27,7 @@ trigger:
|
||||
input:
|
||||
messages: =Workflow.Inputs.input
|
||||
output:
|
||||
autoSend: false
|
||||
response: Local.agentResponse
|
||||
responseObject: Local.orderData
|
||||
|
||||
@@ -37,6 +38,7 @@ trigger:
|
||||
arguments:
|
||||
order_data: =Local.orderData
|
||||
output:
|
||||
autoSend: false
|
||||
result: Local.orderCalculation
|
||||
|
||||
# Invoke another function tool to format the final confirmation
|
||||
@@ -47,6 +49,7 @@ trigger:
|
||||
order_data: =Local.orderData
|
||||
order_calculation: =Local.orderCalculation
|
||||
output:
|
||||
autoSend: false
|
||||
result: Local.confirmation
|
||||
|
||||
# Send the final confirmation to the user
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
This sample demonstrates control flow with conditions:
|
||||
- If/else branching
|
||||
- Switch statements
|
||||
- Nested conditions
|
||||
|
||||
## Files
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# Human-in-Loop Workflow Sample
|
||||
|
||||
This sample demonstrates how to build interactive workflows that request user input during execution using the `Question`, `RequestExternalInput`, and `WaitForInput` actions.
|
||||
This sample demonstrates how to build interactive workflows that request user input during execution using the `Question` and `RequestExternalInput` actions.
|
||||
|
||||
## What This Sample Shows
|
||||
|
||||
- Using `Question` to prompt for user responses
|
||||
- Using `RequestExternalInput` to request external data
|
||||
- Using `WaitForInput` to pause and wait for input
|
||||
- Processing user responses to drive workflow decisions
|
||||
- Interactive conversation patterns
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Hosting Samples
|
||||
|
||||
This directory contains Python samples that demonstrate different ways to host Agent Framework agents. Use this page to choose the hosting model that best fits your scenario, then continue to the README in the relevant subdirectory.
|
||||
|
||||
## Hosting Options
|
||||
|
||||
| Option | Use this when you need... | Start here |
|
||||
|--------|----------------------------|------------|
|
||||
| A2A | Agent-to-Agent protocol interoperability or remote agent invocation. | [`a2a/README.md`](./a2a/README.md) |
|
||||
| Azure Functions | HTTP or serverless hosting on Azure Functions. | [`azure_functions/README.md`](./azure_functions/README.md) |
|
||||
| Durable Task | Durable execution, long-running flows, or orchestration patterns. | [`durabletask/README.md`](./durabletask/README.md) |
|
||||
| Foundry Hosted Agents | Azure AI Foundry hosted agent deployment. | [`foundry-hosted-agents/README.md`](./foundry-hosted-agents/README.md) |
|
||||
|
||||
## How to Choose
|
||||
|
||||
- Start with **A2A** if you want one agent to call or expose another agent over the A2A protocol.
|
||||
- Start with **Azure Functions** if you want an HTTP-hosted or serverless entry point using Azure Functions.
|
||||
- Start with **Durable Task** if you need persistent state, durable workflows, or orchestration across multiple steps.
|
||||
- Start with **Foundry Hosted Agents** if you want to package and deploy an agent as a hosted agent in Azure AI Foundry.
|
||||
|
||||
## Common Prerequisites
|
||||
|
||||
Most hosting samples share a small set of prerequisites:
|
||||
|
||||
- A supported Python environment for running the samples locally.
|
||||
- An Azure AI Foundry project endpoint and model deployment name for `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL`.
|
||||
- Azure CLI authentication via `az login` when the sample uses `AzureCliCredential`.
|
||||
- Any hosting-specific tools or extra services called out in the subdirectory README.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Pick the hosting approach that matches your scenario.
|
||||
2. Open the corresponding README for setup and run instructions.
|
||||
3. Follow that sample's environment, dependency, and execution steps.
|
||||
Generated
+11
-11
@@ -110,7 +110,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -165,7 +165,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-a2a"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
source = { editable = "packages/a2a" }
|
||||
dependencies = [
|
||||
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -313,7 +313,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-chatkit"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
source = { editable = "packages/chatkit" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -323,7 +323,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "openai-chatkit", specifier = ">=1.4.1,<2.0.0" },
|
||||
{ name = "openai-chatkit", specifier = ">=1.6.4,<2.0.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -358,7 +358,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-core"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = { editable = "packages/core" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -432,7 +432,7 @@ provides-extras = ["all"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-declarative"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
source = { editable = "packages/declarative" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -459,7 +459,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-devui"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
source = { editable = "packages/devui" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -524,7 +524,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-foundry"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = { editable = "packages/foundry" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -543,7 +543,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-foundry-hosting"
|
||||
version = "1.0.0a260521"
|
||||
version = "1.0.0a260528"
|
||||
source = { editable = "packages/foundry_hosting" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -604,7 +604,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" },
|
||||
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -754,7 +754,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-openai"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = { editable = "packages/openai" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
|
||||
Reference in New Issue
Block a user