Compare commits

...
Author SHA1 Message Date
Peter Ibekwe 536a998b59 Update package version 2026-05-28 11:14:33 -07:00
Jacob AlberGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
401a552735 .NET: Support ClaimsIdentity-based scoping of agent sessions (#5696)
* feat: Add DelegatingAgentSessionStore

Add helper for decorator pattern for AgentSessionStore

* feat: Add UserIdentityScopedSessionStore

Add support for using the ASP.Net Core ambient `ClaimsIdentity` User, along with a user-specified claim type to scope the session store based on authenticated identity.

* fix: Harden scope mapping

* fix: Add UserIdentityScopeSessionStoreOptions to avoid future breaking changes

* Split UserIdentityScopedSessionStore into a separate IsolationKeyProvider and IsolationKeyScopedSessionStore

* Add GetService<>() capabilities to interrogate AgentSessionStore delegation chain

* Harden default for A2A hosting by using an IsolationKeyScopedAgentSessionStore when no store is available.

* Pipe isolation through Hosting helper extension methods

* Add comment to samples about adding SessionIsolationKeyProvider

* Fix isolation key provider nullability semantics

* fix A2A defaults

* fixup

* remove unneeded keyProvider requirement test

* Add trust-model XML docs to AgentSessionStore, InMemoryAgentSessionStore, MapAGUI, A2A entry points

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/e466c53a-faad-40a8-8b5f-83cf0dce0b1d

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* fix: Switch ClaimsBasedIsolationKeyProvider to be Singleton

   * matches HttpContextAccessor and related MAF services

* release: Ensure new project is in the release filter

* fixup: Integraitaon tests

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-28 17:43:18 +00:00
Peter IbekweandGitHub 718a1f14fd Add missing projects to solution for release (#6157) 2026-05-28 16:47:31 +00:00
f7c5b8d108 Python: [Breaking] Refactor Skill API to async resource and script lookup (#6135)
Port of .NET commit 08541ee5a9.

Replace property-based Skill.content/resources/scripts with async
by-name lookup methods:
- content property -> async get_content() -> str
- resources property -> async get_resource(name) -> SkillResource | None
- scripts property -> async get_script(name) -> SkillScript | None

SkillsProvider now always includes all three tools (load_skill,
read_skill_resource, run_skill_script) and both instruction blocks
regardless of whether any skills have resources or scripts.

ClassSkill retains resources/scripts properties as overridable hooks
for subclass reflection-based discovery.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 15:54:20 +00:00
westeyandGitHub e6762ea876 .NET: Fix render dupe and text input clear bugs, and improve guardrail error messaging (#6136)
* Fix render dupe and text input clear bugs

* Fix another text rendering issue and improve guardrails messaging

* Address PR comments

* Improve guardrail rendering and json error handling

* Another tweak for input box render issue

* Address PR comments
2026-05-28 16:38:04 +01:00
08abe9e704 .NET: Add Foundry Toolbox MCP skills discovery sample (#6134)
* feat: add Agent_Step26_FoundryToolboxMcpSkills sample

Add a new sample demonstrating MCP-based skills discovery from a Foundry
Toolbox endpoint using AgentSkillsProviderBuilder and AIContextProviders.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: address PR review comments for Step26 sample

- Add Foundry-Features: Toolboxes=V1Preview header to MCP transport
  options, matching the Step25 pattern
- Document skill://index.json prerequisite in README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Program.cs

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-05-28 12:41:33 +00:00
Evan MattsonandGitHub a84ad42f6d Bump Python package versions for 1.7.0 release (#6142)
Bumps the released 1.6.0 packages agent-framework, agent-framework-core, agent-framework-foundry, and agent-framework-openai to 1.7.0, with root continuing to exactly pin agent-framework-core[all]. Bumps the changed prerelease packages agent-framework-a2a, agent-framework-chatkit, agent-framework-declarative, agent-framework-devui, and agent-framework-foundry-hosting to the 260528 date stamp, raises core floors on the packages included in this release, raises Foundry's OpenAI floor alongside OpenAI, and raises ChatKit's openai-chatkit floor to the minimum version required by the current typed API usage. No beta cohort bump was applied; the absent mistal/mistral package was intentionally not bumped because no such package exists in this branch.
2026-05-28 19:45:31 +09:00
Peter IbekweandGitHub ded17b178c Python: [Breaking] Remove Python-only declarative actions and rename alias kinds to C# canonical names (#6126)
* Remove Python-only declarative actions and rename alias kinds to C# canonical names

* Address PR comments.

* Address PR comments.

* Reduce verbose and duplicate output from sample workflow.
2026-05-28 10:16:22 +00:00
Yufeng HeandGitHub 55dc3ce734 Python: fix: pass Foundry agent default headers (#6040)
* fix: pass Foundry agent default headers

* test: loosen Foundry default header assertions
2026-05-28 10:08:14 +00:00
BaidarandGitHub 9d8e5ca4f5 Python: Allow hosted checkpoints to restore MessageRole (#6049)
* Python: Allow hosted checkpoints to restore MessageRole

Allow Responses hosting checkpoint storage to deserialize the Azure Responses MessageRole enum that hosted workflows can persist inside Agent Framework Message objects.

Add regression coverage for both direct load() and the hosted get_latest() restore path, including the plain-storage failure mode where list_checkpoints logs the blocked type and get_latest() returns None.

Ruff also normalizes a duplicate contextlib import in the touched hosting module.

* Address MessageRole checkpoint review comments

* Cover hosted MessageRole checkpoint restore path
2026-05-28 09:13:30 +00:00
af787569b3 Python: Align c# and python TodoProvider tool names (#6107)
* Align c# and python TodoProvider tool names

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address PR review: remove __slots__ and add typed schemas for tool params

- Remove __slots__ from TodoItem, TodoInput, and TodoCompleteInput classes
  (not needed for low-instance-count objects and hinders dev scenarios)
- Add _TodoAddItemSchema and _TodoCompleteItemSchema TypedDicts to provide
  proper JSON schema for todos_add and todos_complete tool parameters
- Use typing_extensions for Python 3.10 compatibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 08:40:13 +00:00
3db2004e49 Python: read headers defensively to support stream wrappers without .headers (#6028) (#6029)
`OpenAIChatClient._inner_get_response()` reads `.headers` on the raw streaming
response returned by `client.responses.with_raw_response.create(stream=True)`
(and its three sibling call sites - retrieve-streaming, non-streaming create
and background retrieve) to surface the `x-ms-served-model` Azure header,
introduced in #5910.

When `azure-ai-projects>=2.1.0` experimental GenAI tracing is enabled
(`AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true`), the instrumentor wraps the
raw streaming response in an inline `AsyncStreamWrapper` that exposes
`.response` but not `.headers`. Reading `raw_create_response.headers` then
raises `AttributeError: 'AsyncStreamWrapper' object has no attribute 'headers'`,
which `FoundryChatClient` rethrows as a `ChatClientException` and breaks every
streaming call (workflows and free chat).

Fix: read the header dict via `getattr(raw_response, "headers", None)` at all
four call sites. `_extract_served_model()` already short-circuits on `None`,
so the served-model surfacing degrades gracefully (model stays the deployment
alias) instead of crashing when the response is wrapped by an instrumentor
that does not proxy `.headers`.

Regression test added:
`test_streaming_response_without_headers_attribute_does_not_crash`
simulates a stream wrapper that raises `AttributeError` on `.headers` and
asserts the stream still completes with the deployment alias as `update.model`.

Fixes #6028

Co-authored-by: Emilien Mottet <emilien.mottet@michelin.com>
2026-05-28 08:37:38 +00:00
efdabd56dc feat(a2a): add A2AAgentSession with reference_task_ids and input-required support (#5980)
* feat(a2a): link follow-up messages via reference_task_ids

Track the task_id from A2A responses (task, status_update, artifact_update,
and message payloads) on session.state and include it as reference_task_ids
on subsequent outgoing messages. This enables remote agents to correlate
follow-up messages as task refinements per the A2A spec.

Resolves #5938

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(a2a): add A2AAgentSession for typed protocol state tracking

Introduce A2AAgentSession (subclass of AgentSession) with context_id,
task_id, and task_state properties. This follows the DurableAgentSession
pattern and mirrors the .NET A2AAgentSession design.

- Track task_id, context_id, and task_state from all response payload types
- Validate context_id consistency (raise on mismatch)
- Auto-assign server-generated context_id when not set
- Only A2AAgentSession gets reference tracking (no state dict fallback)
- Plain AgentSession continues to work without reference tracking
- Add serialization support (to_dict/from_dict)
- Export via agent_framework.a2a and agent_framework_a2a

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* style: remove unnecessary string annotation (pyupgrade)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: use AgentSession.from_dict for state deserialization

Avoids importing private _deserialize_state, matching the
DurableAgentSession pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: track context_id from message payloads in A2AAgentSession

Previously, context_id was only captured from task, status_update, and
artifact_update payloads. Message-only responses (which carry context_id
but may lack task_id) were silently lost. This fix:

- Captures msg.context_id in the message handler
- Persists session state when either last_task_id or last_context_id is
  present (not only when task_id is truthy)
- Only updates task_id/task_state when a task_id was actually returned
- Adds a test for message-only context_id tracking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* addressed comments

* Gate status content to INPUT_REQUIRED/terminal states (match .NET)

Match .NET's GetUserInputRequests pattern: only emit TaskStatusUpdateEvent
message content when state is INPUT_REQUIRED or terminal. Intermediate
status text (WORKING, SUBMITTED) is no longer surfaced to callers.

When state is INPUT_REQUIRED, set additional_properties['input_required']
= True so callers can distinguish input requests from final responses.

Closes #5937

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review: remove message task_id tracking, defensive fallbacks, and input_required flag

- Do not track task_id from Message payloads (simple interactions
  without task tracking)
- Remove 'or last_task_id' fallback from status_update and
  artifact_update handlers (spec guarantees task_id is always set)
- Remove additional_properties['input_required'] flag (content gating
  to INPUT_REQUIRED/terminal states is the signal itself)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 08:36:49 +00:00
371a869e44 Fix deprecated asyncio.iscoroutinefunction usage in test_cleanup_hooks.py (#4563)
Fixes #4522

Replace deprecated `asyncio.iscoroutinefunction()` with `inspect.iscoroutinefunction()`
to resolve Python 3.13+ deprecation warning.

Changes:
- Added `import inspect` to imports
- Replaced `asyncio.iscoroutinefunction(hook)` with `inspect.iscoroutinefunction(hook)` on line 126
- This makes the code consistent with other test methods in the same file (lines 201, 236)

The rest of the file already uses `inspect.iscoroutinefunction()` correctly, making
this change consistent with the existing codebase pattern.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Tao Chen <taochen@microsoft.com>
2026-05-28 02:29:31 +00:00
e532ced950 Add hosting samples overview README (#5407)
Co-authored-by: whenpoem <187613766+whenpoem@users.noreply.github.com>
2026-05-27 21:08:17 +00:00
83 changed files with 4282 additions and 1459 deletions
+2
View File
@@ -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" />
+4 -1
View File
@@ -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",
+3 -3
View File
@@ -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>
@@ -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>
@@ -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);
}
}
@@ -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 &lt;= 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;
}
}
@@ -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,
@@ -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;
}
@@ -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,
};
@@ -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();
@@ -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);
}
@@ -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,
@@ -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);
}
}
@@ -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;
}
@@ -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,
}),
@@ -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();
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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>
@@ -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
View File
@@ -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__",
+157 -15
View File
@@ -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.
+2 -2
View File
@@ -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",
]
+346 -50
View File
@@ -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
+3 -3
View File
@@ -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(
+228 -201
View File
@@ -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"]
+1 -1
View File
@@ -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",
]
@@ -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.
"""
@@ -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.
"""
@@ -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,
@@ -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.
"""
@@ -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,
}
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -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
+34
View File
@@ -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.
+11 -11
View File
@@ -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'" },