Compare commits

..
69 changed files with 360 additions and 3118 deletions
+1 -84
View File
@@ -273,8 +273,6 @@ jobs:
-c ${{ matrix.configuration }} `
--no-build -v Normal `
--report-xunit-trx `
--report-junit `
--results-directory ../IntegrationTestResults/ `
--ignore-exit-code 8 `
--filter-not-trait "Category=IntegrationDisabled" `
--filter-not-trait "Category=FoundryHostedAgents" `
@@ -296,10 +294,6 @@ jobs:
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
# Anthropic Models
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL_NAME: ${{ vars.ANTHROPIC_CHAT_MODEL_NAME }}
ANTHROPIC_REASONING_MODEL_NAME: ${{ vars.ANTHROPIC_REASONING_MODEL_NAME }}
# Generate test reports and check coverage
- name: Generate test reports
@@ -322,14 +316,6 @@ jobs:
shell: pwsh
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
- name: Upload integration test results
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
uses: actions/upload-artifact@v7
with:
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
path: IntegrationTestResults/**/*.junit
if-no-files-found: ignore
# The Foundry hosted-agent IT is costly (it builds a container, pushes to ACR, and provisions
# live agents on a separate Foundry project). Running it in its own job keeps the overall
# workflow time roughly flat: it executes in parallel to dotnet-build and dotnet-test and is
@@ -393,14 +379,6 @@ jobs:
# We rebuild and push the test container image on every IT run so framework code changes
# are picked up; the image tag is content-hashed across the test container source AND its
# framework project references, so identical content is a no-op push.
#
# `-UsePrebuiltProjectReferences` opts into the no-rebuild fast path: publish skips
# rebuilding ProjectReferences and consumes the DLLs the prior "Build Foundry hosted IT
# (and its deps)" step already produced. This avoids MSB3026 ("file is being used by
# another process") collisions caused by the previous build's shared-compilation server
# still holding file handles to those DLLs. Safe in CI because the prebuild step ran in
# the same job against the same source. Do not remove the prebuild step (the subsequent
# `dotnet test --no-build` step depends on it too).
- name: Build and push Foundry Hosted Agents test container
id: build-foundry-hosted-image
shell: pwsh
@@ -410,7 +388,7 @@ jobs:
if ([string]::IsNullOrWhiteSpace($registry)) {
throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment."
}
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry -UsePrebuiltProjectReferences | Tee-Object -FilePath $env:GITHUB_ENV -Append
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry | Tee-Object -FilePath $env:GITHUB_ENV -Append
- name: Run Foundry Hosted Agents Integration Tests
shell: pwsh
@@ -470,64 +448,3 @@ jobs:
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
# Integration test trend report (aggregates JUnit XML results from dotnet test jobs)
dotnet-integration-test-report:
name: Integration Test Report
if: >
always() &&
github.event_name != 'pull_request' &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs: [dotnet-test]
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
.github/actions/python-setup
python
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: "3.13"
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
with:
pattern: dotnet-test-results-*
path: dotnet-test-results/
- name: Restore report history cache
uses: actions/cache/restore@v4
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
restore-keys: |
dotnet-integration-report-history-
- name: Generate trend report
run: >
uv run python scripts/integration_test_report/aggregate.py
../dotnet-test-results/
dotnet-integration-report-history.json
dotnet-integration-test-report.md
- name: Post to Job Summary
if: always()
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@v4
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
- name: Upload trend report
if: always()
uses: actions/upload-artifact@v7
with:
name: dotnet-integration-test-report
path: |
python/dotnet-integration-test-report.md
python/dotnet-integration-report-history.json
-1
View File
@@ -33,4 +33,3 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
- [Design Documents](../docs/design)
- [Architectural Decision Records](../docs/decisions)
- [MSFT Learn Docs](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)
@@ -50,12 +50,12 @@ Console.WriteLine(await agent.RunAsync("My name is Ruaidhrí", session));
Console.WriteLine(await agent.RunAsync("I am 20 years old", session));
// We can serialize the session. The serialized state will include the state of the memory component.
JsonElement sessionElement = await agent.SerializeSessionAsync(session);
JsonElement sesionElement = await agent.SerializeSessionAsync(session);
Console.WriteLine("\n>> Use deserialized session with previously created memories\n");
// Later we can deserialize the session and continue the conversation with the previous memory component state.
var deserializedSession = await agent.DeserializeSessionAsync(sessionElement);
var deserializedSession = await agent.DeserializeSessionAsync(sesionElement);
Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedSession));
Console.WriteLine("\n>> Read memories using memory component\n");
@@ -190,7 +190,7 @@ internal static class InputConverter
private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput)
{
var output = DecodeFunctionResultPayload(funcOutput.Output);
var output = funcOutput.Output?.ToString() ?? string.Empty;
return new ChatMessage(
ChatRole.Tool,
[new FunctionResultContent(funcOutput.CallId, output)]);
@@ -482,54 +482,9 @@ internal static class InputConverter
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
{
var output = DecodeFunctionResultPayload(funcOutput.Output);
return new ChatMessage(
ChatRole.Tool,
[new FunctionResultContent(funcOutput.CallId, output)]);
}
/// <summary>
/// Decodes the wire payload of a <c>function_call_output.output</c> field back into the
/// underlying tool-result text suitable for replay as <see cref="FunctionResultContent.Result"/>.
/// </summary>
/// <remarks>
/// Mirrors <c>OutputConverter.EncodeFunctionResultAsJsonStringPayload</c>. Per the OpenAI
/// Responses spec, <c>output</c> is a JSON string; we extract its underlying value. Legacy
/// producers that emitted raw JSON values (arrays/objects) are tolerated by passing the raw
/// bytes through unchanged.
/// </remarks>
private static string DecodeFunctionResultPayload(BinaryData? rawOutput)
{
if (rawOutput is null)
{
return string.Empty;
}
var raw = rawOutput.ToString();
if (string.IsNullOrEmpty(raw))
{
return string.Empty;
}
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind == JsonValueKind.String)
{
return doc.RootElement.GetString() ?? string.Empty;
}
// Legacy/non-conforming producers may have emitted a raw JSON value
// (array/object/number/bool/null). Pass the raw text through as the
// payload so the replayed FunctionResultContent.Result preserves the
// original tool output shape.
return raw;
}
catch (JsonException)
{
// Not valid JSON — treat the bytes as a literal string payload.
return raw;
}
[new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]);
}
private static ChatRole ConvertMessageRole(MessageRole role)
@@ -279,7 +279,12 @@ internal static class OutputConverter
accumulatedText = null;
previousMessageId = null;
var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
var outputText = functionResult.Result switch
{
null => string.Empty,
string s => s,
_ => JsonSerializer.Serialize(functionResult.Result),
};
var itemId = GenerateItemId("fc");
var outputItem = new OutputItemFunctionToolCallOutput(
@@ -443,44 +448,4 @@ internal static class OutputConverter
var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase
return $"{prefix}_{body}";
}
/// <summary>
/// Encodes a <see cref="FunctionResultContent.Result"/> value into the wire payload for
/// the OpenAI Responses <c>function_call_output.output</c> field.
/// </summary>
/// <remarks>
/// The OpenAI Responses spec requires <c>output</c> to be a JSON string. The Responses
/// SDK's <see cref="OutputItemFunctionToolCallOutput"/> accepts a <see cref="BinaryData"/>
/// containing the *raw JSON value* for the field, so the returned text is always a JSON
/// string literal (quoted, with escapes). This avoids two bugs:
/// <list type="bullet">
/// <item>Complex results (e.g. <c>List&lt;TodoItem&gt;</c>) landing on the wire as an
/// unquoted JSON array, which the strict-parsing OpenAI .NET client
/// (<c>FunctionCallOutputResponseItem</c>) rejects with
/// "requires an element of type 'String', but the target element has type 'Array'".</item>
/// <item>Numeric- or JSON-shaped string results (e.g. <c>"42"</c> or <c>"{\"k\":1}"</c>)
/// silently changing type on the wire because <c>BinaryData</c> auto-detects JSON.</item>
/// </list>
/// <see cref="JsonElement"/> / <see cref="JsonDocument"/> values are unwrapped first so
/// a string-kind element does not get double-encoded into <c>"\"value\""</c>.
/// </remarks>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call result payload.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call result payload.")]
private static string EncodeFunctionResultAsJsonStringPayload(object? result)
{
string innerText = result switch
{
null => string.Empty,
string s => s,
JsonElement je => je.ValueKind == JsonValueKind.String
? (je.GetString() ?? string.Empty)
: je.GetRawText(),
JsonDocument jd => jd.RootElement.ValueKind == JsonValueKind.String
? (jd.RootElement.GetString() ?? string.Empty)
: jd.RootElement.GetRawText(),
_ => JsonSerializer.Serialize(result),
};
return JsonSerializer.Serialize(innerText);
}
}
@@ -13,5 +13,5 @@ internal sealed class SequenceNumber
/// Gets the next sequence number.
/// </summary>
/// <returns>The next sequence number.</returns>
public int Increment() => System.Threading.Interlocked.Increment(ref this._sequenceNumber) - 1;
public int Increment() => this._sequenceNumber++;
}
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Extensions.AI;
@@ -16,7 +15,6 @@ namespace Microsoft.Agents.AI.Workflows;
/// contain the latest progress ledger that determined that no progress has been made or the workflow was in
/// a loop.</param>
/// <param name="IsStalled">Whether the workflow is currently stalled.</param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public record MagenticPlanReviewRequest(ChatMessage Plan, MagenticProgressLedger? CurrentProgress, bool IsStalled)
{
/// <summary>
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
@@ -13,7 +12,6 @@ namespace Microsoft.Agents.AI.Workflows;
/// <param name="Review">
/// Review feedback for a generated plan. Empty if the plan is approved as-is and changes are requested.
/// </param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public record MagenticPlanReviewResponse(List<ChatMessage> Review)
{
internal bool IsApproved => this.Review.Count == 0;
@@ -14,7 +14,6 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Maintains a ledger of progress made by the Magentic workflow.
/// </summary>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public class MagenticProgressLedger
{
internal static readonly BooleanProgressLedgerSlot IsRequestSatisfiedSlot = new("is_request_satisfied",
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
@@ -26,7 +25,6 @@ namespace Microsoft.Agents.AI.Workflows;
/// not supported on the ManagerAgent.
/// </summary>
/// <param name="managerAgent"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public class MagenticWorkflowBuilder(AIAgent managerAgent)
{
private readonly List<AIAgent> _team = new();
@@ -2,7 +2,7 @@
<PropertyGroup>
<IsReleased>true</IsReleased>
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
@@ -18,7 +17,6 @@ namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
[JsonDerivedType(typeof(MagenticPlanCreatedEvent))]
[JsonDerivedType(typeof(MagenticReplannedEvent))]
[JsonDerivedType(typeof(MagenticProgressLedgerUpdatedEvent))]
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public abstract class MagenticOrchestratorEvent(object? data) : WorkflowEvent(data)
{
}
@@ -27,7 +25,6 @@ public abstract class MagenticOrchestratorEvent(object? data) : WorkflowEvent(da
/// Represents the creation of the initial plan
/// </summary>
/// <param name="fullTaskLeger"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
{
/// <summary>
@@ -40,7 +37,6 @@ public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : Magent
/// Represents the creation of a new plan in response to a stall.
/// </summary>
/// <param name="fullTaskLeger"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
{
/// <summary>
@@ -53,7 +49,6 @@ public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : Magentic
/// Represents an update to the <see cref="MagenticProgressLedger"/> when running a coordination round.
/// </summary>
/// <param name="progressLedger"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public sealed class MagenticProgressLedgerUpdatedEvent(MagenticProgressLedger progressLedger) : MagenticOrchestratorEvent(progressLedger)
{
/// <summary>
@@ -17,6 +17,9 @@ namespace AnthropicChatCompletion.IntegrationTests;
public class AnthropicChatCompletionFixture : IChatClientAgentFixture
{
// All tests for Anthropic are intended to be ran locally as the CI pipeline for Anthropic is not setup.
internal const string SkipReason = "Integrations tests for local execution only";
private readonly bool _useReasoningModel;
private readonly bool _useBeta;
@@ -102,22 +105,7 @@ public class AnthropicChatCompletionFixture : IChatClientAgentFixture
public async ValueTask InitializeAsync()
{
// Temporarily disabled: Anthropic SDK has a binary incompatibility with the current
// Microsoft.Extensions.AI version (WebSearchToolResultContent.Results method not found).
// See: https://github.com/microsoft/agent-framework/pull/5515
Assert.Skip("Anthropic integration tests temporarily disabled due to SDK incompatibility with Microsoft.Extensions.AI");
try
{
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey);
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicReasoningModelName);
}
catch (InvalidOperationException ex)
{
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
}
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
this._agent = await this.CreateChatClientAgentAsync();
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Anthropic;
@@ -18,28 +17,19 @@ namespace AnthropicChatCompletion.IntegrationTests;
/// Integration tests for Anthropic Skills functionality.
/// These tests are designed to be run locally with a valid Anthropic API key.
/// </summary>
/// <remarks>
/// Temporarily disabled due to Anthropic SDK binary incompatibility with
/// the current Microsoft.Extensions.AI version (WebSearchToolResultContent.Results).
/// </remarks>
[Trait("Category", "IntegrationDisabled")]
public sealed class AnthropicSkillsIntegrationTests
{
// All tests for Anthropic are intended to be ran locally as the CI pipeline for Anthropic is not setup.
private const string SkipReason = "Integrations tests for local execution only";
[Fact]
public async Task CreateAgentWithPptxSkillAsync()
{
AnthropicClient? anthropicClient;
string? model;
try
{
anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
model = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
}
catch (InvalidOperationException ex)
{
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
return;
}
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
// Arrange
AnthropicClient anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
string model = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
BetaSkillParams pptxSkill = new()
{
@@ -66,16 +56,10 @@ public sealed class AnthropicSkillsIntegrationTests
[Fact]
public async Task ListAnthropicManagedSkillsAsync()
{
AnthropicClient? anthropicClient;
try
{
anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
}
catch (InvalidOperationException ex)
{
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
return;
}
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
// Arrange
AnthropicClient anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
// Act
SkillListPage skills = await anthropicClient.Beta.Skills.List(
@@ -41,14 +41,7 @@ param(
[string] $Repository = "foundry-hosting-it",
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer",
# Explicit opt-in for the no-rebuild fast path. CI sets this after running the
# "Build Foundry hosted IT (and its deps)" step, which guarantees the prebuilt
# library DLLs match current source. Off by default so local invocations always
# let publish rebuild ProjectReferences and never produce an image whose tag is
# computed from current source while the contents come from a stale build.
[switch] $UsePrebuiltProjectReferences
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer"
)
$ErrorActionPreference = "Stop"
@@ -107,60 +100,7 @@ if (Test-Path $out) {
Remove-Item -Recurse -Force $out
}
# Conditionally tell publish to skip rebuilding ProjectReferences and consume the
# prebuilt library DLLs in place. This avoids two failure modes that arise when
# the CI workflow runs a `dotnet build` of the same library projects immediately
# before this script:
# 1) MSB3026 "file is being used by another process" when publish's MSBuild
# tries to overwrite src/<lib>/bin/Release/net10.0/<lib>.dll while the
# previous build's shared-compilation server still holds a file handle.
# 2) Publish needlessly rebuilding identical managed (RID-agnostic) library
# DLLs that prebuild already produced.
# Gated on -UsePrebuiltProjectReferences (a strict opt-in) instead of marker
# detection, because a developer machine may have a stale Release build of the
# libraries from days ago; using those would silently produce an image whose
# content is older than the source the tag is computed from.
$publishExtraArgs = @()
if ($UsePrebuiltProjectReferences) {
Write-Host "-UsePrebuiltProjectReferences: skipping ProjectReference rebuild." -ForegroundColor DarkGray
$publishExtraArgs += "-p:BuildProjectReferences=false"
} else {
# Preflight: in default (rebuild) mode, publish propagates RuntimeIdentifier=linux-musl-x64
# to library ProjectReferences and writes their intermediates to a RID-suffixed obj path
# (e.g. obj/Release/net10.0/linux-musl-x64/). DefaultItemExcludes follows the new
# IntermediateOutputPath, so any *.AssemblyInfo.cs left in obj/Release/net10.0/ from a
# prior `dotnet build` is no longer excluded and gets picked up by the **/*.cs Compile
# glob, producing CS0579 "duplicate attribute" errors. Detect that state up front and
# tell the user exactly how to recover.
$staleObjProbes = @(
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/obj/Release/net10.0",
"dotnet/src/Microsoft.Agents.AI.Foundry/obj/Release/net10.0",
"dotnet/src/Microsoft.Agents.AI/obj/Release/net10.0",
"dotnet/src/Microsoft.Agents.AI.Abstractions/obj/Release/net10.0"
)
$stale = @($staleObjProbes | Where-Object { Test-Path (Join-Path $_ "*.AssemblyInfo.cs") })
if ($stale.Count -gt 0) {
$msg = @(
"Detected prior Release/net10.0 build outputs in:"
($stale | ForEach-Object { " - $_" })
""
"Publish would propagate -r linux-musl-x64 to those ProjectReferences and the"
"leftover obj/Release/net10.0/*.AssemblyInfo.cs files would cause CS0579 duplicate"
"attribute errors. Pick one:"
" (a) Pass -UsePrebuiltProjectReferences (skips ProjectReference rebuild and"
" uses the existing src/<lib>/bin/Release/net10.0/*.dll outputs in place)."
" Only safe when you know those DLLs match current source - this is the path"
" CI uses immediately after its 'Build Foundry hosted IT (and its deps)' step."
" (b) Remove the stale obj/Release trees, e.g.:"
" Remove-Item -Recurse -Force dotnet/src/Microsoft.Agents.AI*/obj/Release"
" and re-run."
) -join "`n"
throw $msg
}
Write-Host "Letting publish build ProjectReferences (pass -UsePrebuiltProjectReferences in CI to skip)." -ForegroundColor DarkGray
}
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out @publishExtraArgs --tl:off | Out-Host
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out --tl:off | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
@@ -13,6 +13,8 @@ namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
[Trait("Category", "SampleValidation")]
public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : SamplesValidationBase(outputHelper)
{
private const string SkipFlakyTimingTest = "Flaky: timing-dependent LLM test, see https://github.com/microsoft/agent-framework/issues/4971";
private static readonly string s_samplesPath = Path.GetFullPath(
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "04-hosting", "DurableAgents", "ConsoleApps"));
@@ -67,7 +69,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task SingleAgentOrchestrationChainingSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
@@ -103,7 +105,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task MultiAgentConcurrencySampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
@@ -158,7 +160,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task MultiAgentConditionalSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
@@ -235,14 +237,14 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
Assert.True(foundSuccess, "Orchestration did not complete successfully.");
}
[RetryFact(2, 5000)]
[Fact(Skip = SkipFlakyTimingTest)]
public async Task SingleAgentOrchestrationHITLSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(180));
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
// Start the HITL orchestration following the happy path from README
await this.WriteInputAsync(process, "The Future of Artificial Intelligence", testTimeoutCts.Token);
@@ -258,7 +260,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
// Look for notification that content is ready. The first time we see this, we should send a rejection.
// Subsequent times we see this, we should send approval (LLM may produce extra review cycles).
// The second time we see this, we should send approval.
if (line.Contains("Content is ready for review", StringComparison.OrdinalIgnoreCase))
{
if (!rejectionSent)
@@ -273,15 +275,20 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
testTimeoutCts.Token);
rejectionSent = true;
}
else
else if (!approvalSent)
{
// Approve any subsequent draft (LLM non-determinism may produce extra review cycles)
// Prompt: Approve? (y/n):
await this.WriteInputAsync(process, "y", testTimeoutCts.Token);
// Prompt: Feedback (optional):
await this.WriteInputAsync(process, "Looks good!", testTimeoutCts.Token);
approvalSent = true;
}
else
{
// This should never happen
Assert.Fail("Unexpected message found.");
}
}
// Look for success message
@@ -304,14 +311,14 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
});
}
[RetryFact(2, 5000)]
[Fact(Skip = SkipFlakyTimingTest)]
public async Task LongRunningToolsSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// This test takes a bit longer to run due to the multiple agent interactions and the lengthy content generation.
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(180));
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(90));
// Test starting an agent that schedules a content generation orchestration
await this.WriteInputAsync(
@@ -328,7 +335,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
// Look for notification that content is ready. The first time we see this, we should send a rejection.
// Subsequent times we see this, we should send approval (LLM may produce extra review cycles).
// The second time we see this, we should send approval.
if (line.Contains("NOTIFICATION: Please review the following content for approval", StringComparison.OrdinalIgnoreCase))
{
// Wait for the notification to be fully written to the console
@@ -343,15 +350,20 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
testTimeoutCts.Token);
rejectionSent = true;
}
else
else if (!approvalSent)
{
// Approve any subsequent draft (LLM non-determinism may produce extra review cycles)
// Approve the content. Note that we need to send a newline character to the console first before sending the input.
await this.WriteInputAsync(
process,
"\nApprove the content",
testTimeoutCts.Token);
approvalSent = true;
}
else
{
// This should never happen
Assert.Fail("Unexpected message found.");
}
}
// Look for success message
@@ -384,14 +396,14 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
});
}
[RetryFact(2, 5000)]
[Fact(Skip = SkipFlakyTimingTest)]
public async Task ReliableStreamingSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "07_ReliableStreaming");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// This test takes a bit longer to run due to the multiple agent interactions and the lengthy content generation.
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(150));
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(90));
// Test the agent endpoint with a simple prompt
await this.WriteInputAsync(process, "Plan a 5-day trip to Seattle. Include daily activities.", testTimeoutCts.Token);
@@ -19,9 +19,11 @@ namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
[Trait("Category", "Integration")]
public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDisposable
{
private const string SkipFlakyTimingTest = "Flaky: timing-dependent LLM test, see https://github.com/microsoft/agent-framework/issues/4971";
private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached
? TimeSpan.FromMinutes(5)
: TimeSpan.FromSeconds(120);
: TimeSpan.FromSeconds(60);
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
@@ -36,7 +38,7 @@ public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDispo
public void Dispose() => this._cts.Dispose();
[RetryFact(2, 5000)]
[Fact]
public async Task SimplePromptAsync()
{
// Setup
@@ -75,7 +77,7 @@ public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDispo
Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentResponse");
}
[RetryFact(2, 5000)]
[Fact(Skip = SkipFlakyTimingTest)]
public async Task CallFunctionToolsAsync()
{
int weatherToolInvocationCount = 0;
@@ -127,7 +129,7 @@ public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDispo
Assert.Equal(1, packingListToolInvocationCount);
}
[RetryFact(2, 5000)]
[Fact(Skip = SkipFlakyTimingTest)]
public async Task CallLongRunningFunctionToolsAsync()
{
[Description("Starts a greeting workflow and returns the workflow instance ID")]
@@ -217,7 +217,7 @@ public abstract class SamplesValidationBase : IAsyncLifetime
/// </summary>
protected CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null)
{
TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(120);
TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(60);
return new CancellationTokenSource(testTimeout);
}
@@ -22,7 +22,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
/// <inheritdoc />
protected override string TaskHubPrefix => "workflow";
[RetryFact(2, 5000)]
[Fact]
public async Task SequentialWorkflowSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -71,7 +71,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task ConcurrentWorkflowSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -120,7 +120,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task ConditionalEdgesWorkflowSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -182,7 +182,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
}
}
[RetryFact(2, 5000)]
[Fact]
public async Task WorkflowEventsSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -278,7 +278,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task WorkflowSharedStateSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -376,7 +376,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task SubWorkflowsSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -452,7 +452,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task WorkflowHITLSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -505,7 +505,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task WorkflowAndAgentsSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -37,7 +37,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
public async Task CreateAsync_DefaultAgent_EmitsInvokeAgentSpanAsync()
{
// Arrange
var activities = new ConcurrentActivityList();
var activities = new List<Activity>();
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(activities)
@@ -56,7 +56,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — filter by agent name to isolate this test's span from any parallel test spans
var mySpan = Assert.Single(activities.Snapshot().Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
Assert.Equal("invoke_agent", mySpan.GetTagItem("gen_ai.operation.name"));
Assert.NotNull(mySpan.GetTagItem("gen_ai.agent.id"));
}
@@ -65,7 +65,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
public async Task CreateAsync_KeyedAgent_EmitsInvokeAgentSpanAsync()
{
// Arrange
var activities = new ConcurrentActivityList();
var activities = new List<Activity>();
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(activities)
@@ -84,7 +84,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — filter by agent name to isolate this test's span
var mySpan = Assert.Single(activities.Snapshot().Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
Assert.Equal("invoke_agent", mySpan.GetTagItem("gen_ai.operation.name"));
}
@@ -95,8 +95,8 @@ public class AgentFrameworkResponseHandlerTelemetryTests
// If ApplyOpenTelemetry double-wraps, an extra span would appear on ResponsesSourceName.
// If it correctly skips wrapping, only the pre-wrap's unique source emits spans.
var preWrapSource = Guid.NewGuid().ToString();
var preWrapActivities = new ConcurrentActivityList();
var responsesActivities = new ConcurrentActivityList();
var preWrapActivities = new List<Activity>();
var responsesActivities = new List<Activity>();
using var preWrapProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(preWrapSource)
@@ -125,19 +125,18 @@ public class AgentFrameworkResponseHandlerTelemetryTests
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — pre-wrap source emits exactly 1 span (agent ran)
var preWrapSnapshot = preWrapActivities.Snapshot();
Assert.Single(preWrapSnapshot);
Assert.Equal("invoke_agent", preWrapSnapshot[0].GetTagItem("gen_ai.operation.name"));
Assert.Single(preWrapActivities);
Assert.Equal("invoke_agent", preWrapActivities[0].GetTagItem("gen_ai.operation.name"));
// ResponsesSourceName emits 0 spans — ApplyOpenTelemetry skipped wrapping the pre-instrumented agent
Assert.DoesNotContain(responsesActivities.Snapshot(), a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name")));
Assert.DoesNotContain(responsesActivities, a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name")));
}
[Fact]
public async Task CreateAsync_DefaultAgent_SpanDisplayNameContainsAgentNameAsync()
{
// Arrange
var activities = new ConcurrentActivityList();
var activities = new List<Activity>();
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(ResponsesSourceName)
.AddInMemoryExporter(activities)
@@ -156,7 +155,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
// Assert — display name follows "invoke_agent {Name}({Id})" convention; filter by agent name to isolate
var mySpan = Assert.Single(activities.Snapshot().Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
Assert.Contains("invoke_agent", mySpan.DisplayName, StringComparison.Ordinal);
Assert.Contains(TelemetryTestAgent.AgentName, mySpan.DisplayName, StringComparison.Ordinal);
}
@@ -232,35 +231,4 @@ public class AgentFrameworkResponseHandlerTelemetryTests
}
private sealed class TelemetryAgentSession : AgentSession;
/// <summary>
/// Thread-safe <see cref="ICollection{Activity}"/> used by OTel's InMemoryExporter to capture
/// activities emitted on globally-listened sources. Required because the exporter writes into
/// the supplied collection from background Activity completion callbacks while the test thread
/// may be enumerating it for assertions, and other tests in the same assembly may emit on the
/// same source concurrently. A plain <see cref="List{Activity}"/> trips
/// "Collection was modified; enumeration operation may not execute." in that scenario.
/// </summary>
private sealed class ConcurrentActivityList : ICollection<Activity>
{
private readonly List<Activity> _items = new();
private readonly object _gate = new();
public int Count { get { lock (this._gate) { return this._items.Count; } } }
public bool IsReadOnly => false;
public void Add(Activity item) { lock (this._gate) { this._items.Add(item); } }
public void Clear() { lock (this._gate) { this._items.Clear(); } }
public bool Contains(Activity item) { lock (this._gate) { return this._items.Contains(item); } }
public void CopyTo(Activity[] array, int arrayIndex) { lock (this._gate) { this._items.CopyTo(array, arrayIndex); } }
public bool Remove(Activity item) { lock (this._gate) { return this._items.Remove(item); } }
public Activity[] Snapshot()
{
lock (this._gate) { return this._items.ToArray(); }
}
public IEnumerator<Activity> GetEnumerator() => ((IEnumerable<Activity>)this.Snapshot()).GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => this.GetEnumerator();
}
}
@@ -207,10 +207,9 @@ public class InputConverterTests
[Fact]
public void ConvertOutputItemsToMessages_FunctionToolCallOutput_ReturnsToolMessage()
{
// Spec-compliant payload: a JSON string literal.
var funcOutput = new OutputItemFunctionToolCallOutput(
callId: "call_def",
output: BinaryData.FromString("\"result data\""));
output: BinaryData.FromString("result data"));
var messages = InputConverter.ConvertOutputItemsToMessages([funcOutput]);
@@ -219,52 +218,6 @@ public class InputConverterTests
var result = messages[0].Contents.OfType<FunctionResultContent>().FirstOrDefault();
Assert.NotNull(result);
Assert.Equal("call_def", result.CallId);
// Round-trip: the JSON-string wire payload is unwrapped to the original tool result text.
Assert.Equal("result data", result.Result as string);
}
[Fact]
public void ConvertOutputItemsToMessages_FunctionToolCallOutput_LegacyRawJsonArray_PassesThrough()
{
// Legacy/non-conforming producers that emitted a raw JSON value (array/object) in
// `output` are tolerated: the raw text is forwarded as the FunctionResultContent.Result
// so the model still sees the original tool-output shape on replay.
var funcOutput = new OutputItemFunctionToolCallOutput(
callId: "call_legacy",
output: BinaryData.FromString("[{\"id\":1}]"));
var messages = InputConverter.ConvertOutputItemsToMessages([funcOutput]);
var result = messages[0].Contents.OfType<FunctionResultContent>().FirstOrDefault();
Assert.NotNull(result);
Assert.Equal("[{\"id\":1}]", result.Result as string);
}
[Fact]
public void ConvertInputToMessages_FunctionCallOutput_JsonStringPayload_Unwraps()
{
// Spec-compliant inbound payload — a JSON string literal — must be unwrapped so
// FunctionResultContent.Result is the original tool result text, not the JSON-encoded form.
var input = new[]
{
new
{
type = "function_call_output",
id = "fc_out_002",
call_id = "call_456",
output = "sunny"
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
Assert.Single(messages);
var funcResult = messages[0].Contents.OfType<FunctionResultContent>().FirstOrDefault();
Assert.NotNull(funcResult);
Assert.Equal("sunny", funcResult.Result as string);
}
[Fact]
@@ -616,10 +616,9 @@ public class OutputConverterTests
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// K-06: FRC payloads are wrapped as JSON string literals on the wire so the field is
// always a spec-compliant OpenAI Responses `function_call_output.output` string value.
// K-06: FRC string results are emitted as raw text on the wire (not JSON-quoted).
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultStringPayload_EmittedAsJsonStringAsync()
public async Task ConvertUpdatesToEventsAsync_FunctionResultStringPayload_EmittedAsRawTextAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "sunny")] };
@@ -632,76 +631,8 @@ public class OutputConverterTests
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var output = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
// The wire payload is a JSON string literal — `"sunny"`, not the bare bytes `sunny`.
Assert.Equal("\"sunny\"", output.Output.ToString());
}
// K-06b: List/object FRC payloads must be JSON-stringified into a JSON string value
// so the OpenAI .NET client (FunctionCallOutputResponseItem.Output: string) can parse them.
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultObjectPayload_EmittedAsJsonStringAsync()
{
var (stream, _) = CreateTestStream();
var todoList = new[] { new { id = 1, text = "Buy milk" } };
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", todoList)] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var output = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
// The wire payload must be a quoted JSON string containing the JSON-serialized object.
var raw = output.Output.ToString();
Assert.StartsWith("\"", raw);
Assert.EndsWith("\"", raw);
// The unwrapped value must round-trip back to the original JSON.
var inner = System.Text.Json.JsonSerializer.Deserialize<string>(raw);
Assert.Equal("[{\"id\":1,\"text\":\"Buy milk\"}]", inner);
}
// K-06c: A JsonElement of kind String must not be double-encoded.
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultJsonElementStringPayload_NotDoubleEncodedAsync()
{
var (stream, _) = CreateTestStream();
using var doc = System.Text.Json.JsonDocument.Parse("\"sunny\"");
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", doc.RootElement.Clone())] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var output = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
// Must be `"sunny"`, not `"\"sunny\""`.
Assert.Equal("\"sunny\"", output.Output.ToString());
}
// K-06d: A JsonElement of non-string kind (e.g. array) must be JSON-stringified, not
// emitted as a raw JSON array on the wire.
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultJsonElementArrayPayload_EmittedAsJsonStringAsync()
{
var (stream, _) = CreateTestStream();
using var doc = System.Text.Json.JsonDocument.Parse("[{\"id\":1}]");
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", doc.RootElement.Clone())] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var output = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
var raw = output.Output.ToString();
var inner = System.Text.Json.JsonSerializer.Deserialize<string>(raw);
Assert.Equal("[{\"id\":1}]", inner);
// String FRC payloads must not be double-encoded — `sunny`, not `"sunny"`.
Assert.Equal("sunny", output.Output.ToString());
}
// L-01
@@ -15,6 +15,8 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests;
[Trait("Category", "SampleValidation")]
public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime
{
private const string SkipFlakyTimingTest = "Flaky: timing-dependent LLM test, see https://github.com/microsoft/agent-framework/issues/4971";
private const string AzureFunctionsPort = "7071";
private const string AzuritePort = "10000";
private const string DtsPort = "8080";
@@ -35,7 +37,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
.Build();
private static bool s_infrastructureStarted;
private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(3);
private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(2);
// In CI, `dotnet run` builds the Functions project from scratch before the host starts, so 60s is not enough.
private static readonly TimeSpan s_functionsReadyTimeout = TimeSpan.FromSeconds(180);
@@ -60,7 +62,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
await Task.CompletedTask;
}
[RetryFact(2, 5000)]
[Fact]
public async Task SingleAgentSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent");
@@ -105,7 +107,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[Fact(Skip = "Flaky: LLM non-determinism can produce null orchestration results")]
[Fact]
public async Task SingleAgentOrchestrationChainingSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "02_AgentOrchestration_Chaining");
@@ -148,7 +150,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task MultiAgentOrchestrationConcurrentSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency");
@@ -198,7 +200,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task MultiAgentOrchestrationConditionalsSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals");
@@ -216,7 +218,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task SingleAgentOrchestrationHITLSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL");
@@ -272,7 +274,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000)]
[Fact(Skip = SkipFlakyTimingTest)]
public async Task LongRunningToolsSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools");
@@ -314,7 +316,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
}
},
message: "Orchestration is requesting human feedback",
timeout: TimeSpan.FromSeconds(180));
timeout: TimeSpan.FromSeconds(60));
// Approve the content
Uri approvalUri = new($"{runAgentUri}?thread_id={sessionId}");
@@ -334,7 +336,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
}
},
message: "Content published notification is logged",
timeout: TimeSpan.FromSeconds(180));
timeout: TimeSpan.FromSeconds(60));
// Verify the final orchestration status by asking the agent for the status
Uri statusUri = new($"{runAgentUri}?thread_id={sessionId}");
@@ -358,11 +360,11 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
return isCompleted && hasContent;
},
message: "Orchestration is completed",
timeout: TimeSpan.FromSeconds(180));
timeout: TimeSpan.FromSeconds(60));
});
}
[RetryFact(2, 5000)]
[Fact]
public async Task AgentAsMcpToolAsync()
{
string samplePath = Path.Combine(s_samplesPath, "07_AgentAsMcpTool");
@@ -402,7 +404,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000)]
[Fact(Skip = SkipFlakyTimingTest)]
public async Task ReliableStreamingSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "08_ReliableStreaming");
-32
View File
@@ -7,38 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.3.0] - 2026-05-07
### Added
- **agent-framework-core**: Add `ClassSkill` for class-based skill definitions with declarative metadata and automatic method discovery ([#5678](https://github.com/microsoft/agent-framework/pull/5678))
- **agent-framework-core**: Add experimental session-mode harness context provider ([#5611](https://github.com/microsoft/agent-framework/pull/5611))
- **agent-framework-core**: Add experimental todo-list harness context provider ([#5612](https://github.com/microsoft/agent-framework/pull/5612))
- **agent-framework-core**: Add experimental memory harness context provider ([#5613](https://github.com/microsoft/agent-framework/pull/5613))
- **agent-framework-core**: Notify agent of external `AgentModeProvider` mode changes ([#5650](https://github.com/microsoft/agent-framework/pull/5650))
- **agent-framework-core**: Information-flow control prompt injection defense ([#5331](https://github.com/microsoft/agent-framework/pull/5331))
- **agent-framework-openai**: Support OpenAI and Gemini `allowed_tools` tool choice ([#5322](https://github.com/microsoft/agent-framework/pull/5322))
- **agent-framework-openai**: Support GPT-5 verbosity option and restore Foundry `agent_reference` ([#5619](https://github.com/microsoft/agent-framework/pull/5619))
- **agent-framework-anthropic**: Add `base_url` parameter to `AnthropicClient` and `RawAnthropicClient` ([#5685](https://github.com/microsoft/agent-framework/pull/5685))
- **agent-framework-foundry-hosting**: Add support for function approval flow in Foundry hosted agent ([#5666](https://github.com/microsoft/agent-framework/pull/5666))
- **agent-framework-declarative**: Add Python parity for `InvokeMcpTool` in declarative workflow ([#5630](https://github.com/microsoft/agent-framework/pull/5630))
- **agent-framework-declarative**: Add Python parity for `HttpRequestAction` in declarative workflow ([#5599](https://github.com/microsoft/agent-framework/pull/5599))
- **agent-framework-claude**, **agent-framework-github-copilot**: Enforce `approval_mode` in Claude and GitHub Copilot agents ([#5562](https://github.com/microsoft/agent-framework/pull/5562))
- **agent-framework-github-copilot**: Upgrade `github-copilot-sdk` to v1.0.0b2 with `instruction_directories`, `copilot_home`, and runtime options forwarding on session resume ([#5665](https://github.com/microsoft/agent-framework/pull/5665))
- **samples**: Add hosted agent sample with observability ([#5608](https://github.com/microsoft/agent-framework/pull/5608))
- **samples**: Add sample for hosted agent with files ([#5596](https://github.com/microsoft/agent-framework/pull/5596))
### Changed
- **agent-framework-core**: [BREAKING — experimental skills API] Restructure agent skills to use multi-source architecture ([#5584](https://github.com/microsoft/agent-framework/pull/5584))
- **agent-framework-foundry**: Remove bespoke Foundry toolbox helpers; standardize on MCP for toolbox consumption ([#5671](https://github.com/microsoft/agent-framework/pull/5671))
### Fixed
- **agent-framework-core**: Fix `MCPStreamableHTTPTool` leaking `asyncio.CancelledError` when MCP server is unreachable ([#5687](https://github.com/microsoft/agent-framework/pull/5687))
- **agent-framework-openai**: Drop completed `continuation_token` from shared options in tool loop ([#5462](https://github.com/microsoft/agent-framework/pull/5462))
- **agent-framework-bedrock**: Don't send `toolChoice` when no tools are configured ([#5172](https://github.com/microsoft/agent-framework/pull/5172))
- **agent-framework-hyperlight**: Fix `WasmSandbox` cross-thread Drop and harden hosted-agent sample ([#5603](https://github.com/microsoft/agent-framework/pull/5603))
- **agent-framework-devui**: Fix incorrect workflow timings by adding `created_at` to executor events ([#5615](https://github.com/microsoft/agent-framework/pull/5615))
- **agent-framework-foundry-hosting**: Fix hosted MCP replay producing orphan `function_call_output` ([#5581](https://github.com/microsoft/agent-framework/pull/5581))
## [1.2.2] - 2026-04-29
### Added
+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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"a2a-sdk>=0.3.5,<0.3.24",
]
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260507"
version = "1.0.0b260429"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"ag-ui-protocol>=0.1.16,<0.2",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260507"
version = "1.0.0a260429"
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.3.0,<2",
"agent-framework-foundry>=1.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"agent-framework-foundry>=1.2.2,<2",
"azure-ai-contentunderstanding>=1.0.1,<1.1",
"aiohttp>=3.9,<4",
"filetype>=1.2,<2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
+2 -2
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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"openai-chatkit>=1.4.1,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
@@ -135,7 +135,6 @@ from ._sessions import (
from ._settings import SecretString, load_settings
from ._skills import (
AggregatingSkillsSource,
ClassSkill,
DeduplicatingSkillsSource,
DelegatingSkillsSource,
FileSkill,
@@ -346,7 +345,6 @@ __all__ = [
"ChatResponseUpdate",
"CheckResult",
"CheckpointStorage",
"ClassSkill",
"CompactionProvider",
"CompactionStrategy",
"Content",
+10 -53
View File
@@ -158,22 +158,6 @@ def streamable_http_client(*args: Any, **kwargs: Any) -> _AsyncGeneratorContextM
return _streamable_http_client(*args, **kwargs) # type: ignore[return-value]
def _should_propagate_cancelled_error(ex: BaseException) -> bool:
"""Return True if *ex* is a genuine task-cancellation that should propagate unchanged.
On Python >= 3.11, ``task.cancelling() > 0`` distinguishes a real caller-driven
cancellation from a CancelledError raised internally by a library (e.g. via an
anyio cancel scope). On older Python versions the API is unavailable, so we
always return False and let callers wrap the error in ToolException instead.
"""
if not isinstance(ex, asyncio.CancelledError):
return False
if sys.version_info < (3, 11):
return False
task = asyncio.current_task()
return task is not None and task.cancelling() > 0
# region: MCP Plugin
@@ -643,17 +627,6 @@ class MCPTool:
except asyncio.CancelledError:
logger.warning("Could not cleanly close MCP exit stack because the lifecycle owner task was cancelled.")
async def _close_and_check_cancelled(self, ex: BaseException) -> bool:
"""Close the exit stack and return True if *ex* is a genuine task cancellation.
Callers should immediately re-raise when this returns True::
if await self._close_and_check_cancelled(ex):
raise
"""
await self._safe_close_exit_stack()
return _should_propagate_cancelled_error(ex)
async def connect(self, *, reset: bool = False) -> None:
if self._is_lifecycle_owner_task():
await self._connect_on_owner(reset=reset)
@@ -682,23 +655,14 @@ class MCPTool:
if not self.session:
try:
transport = await self._exit_stack.enter_async_context(self.get_mcp_client())
except (Exception, asyncio.CancelledError) as ex:
# On Python >= 3.11, re-raise genuine task cancellation (task.cancelling() > 0)
# instead of wrapping it in ToolException. On Python < 3.11, task.cancelling()
# is unavailable so MCP-internal CancelledErrors cannot be distinguished from
# caller-driven cancellation; they are wrapped as ToolException in that case.
if await self._close_and_check_cancelled(ex):
raise
except Exception as ex:
await self._safe_close_exit_stack()
command = getattr(self, "command", None)
if command:
error_msg = f"Failed to start MCP server '{command}': {ex}"
else:
error_msg = f"Failed to connect to MCP server: {ex}"
# CancelledError is a BaseException (not Exception) on Python >= 3.8, so
# inner_exception=None and ToolException.__init__ won't log exc_info.
if isinstance(ex, asyncio.CancelledError):
logger.debug(error_msg, exc_info=True)
raise ToolException(error_msg, inner_exception=ex if isinstance(ex, Exception) else None) from ex
raise ToolException(error_msg, inner_exception=ex) from ex
try:
try:
from mcp import types
@@ -728,21 +692,16 @@ class MCPTool:
sampling_capabilities=sampling_capabilities,
)
)
except (Exception, asyncio.CancelledError) as ex:
if await self._close_and_check_cancelled(ex):
raise
session_error_msg = f"Failed to create MCP session: {ex}"
if isinstance(ex, asyncio.CancelledError):
logger.debug(session_error_msg, exc_info=True)
except Exception as ex:
await self._safe_close_exit_stack()
raise ToolException(
message=session_error_msg,
inner_exception=ex if isinstance(ex, Exception) else None,
message="Failed to create MCP session. Please check your configuration.",
inner_exception=ex,
) from ex
try:
await session.initialize()
except (Exception, asyncio.CancelledError) as ex:
if await self._close_and_check_cancelled(ex):
raise
except Exception as ex:
await self._safe_close_exit_stack()
# Provide context about initialization failure
command = getattr(self, "command", None)
if command:
@@ -751,9 +710,7 @@ class MCPTool:
error_msg = f"MCP server '{full_command}' failed to initialize: {ex}"
else:
error_msg = f"MCP server failed to initialize: {ex}"
if isinstance(ex, asyncio.CancelledError):
logger.debug(error_msg, exc_info=True)
raise ToolException(error_msg, inner_exception=ex if isinstance(ex, Exception) else None) from ex
raise ToolException(error_msg, inner_exception=ex) from ex
self.session = session
elif self.session._request_id == 0: # type: ignore[attr-defined]
# If the session is not initialized, we need to reinitialize it
+41 -482
View File
@@ -5,7 +5,7 @@
Defines the core data model classes for the agent skills system:
- **Skills:** :class:`Skill` (abstract base), :class:`InlineSkill` (code-defined),
:class:`ClassSkill` (class-based), and :class:`FileSkill` (filesystem-backed).
and :class:`FileSkill` (filesystem-backed).
- **Resources:** :class:`SkillResource` (abstract base), :class:`InlineSkillResource`
(static content or callable).
- **Scripts:** :class:`SkillScript` (abstract base), :class:`InlineSkillScript`
@@ -27,9 +27,6 @@ Skills can come from different sources:
Represented as :class:`FileSkill` instances.
- **Code-defined** — created as :class:`InlineSkill` instances in Python code,
with optional callable resources attached via the ``@skill.resource`` decorator.
- **Class-based** — created by subclassing :class:`ClassSkill` to define
self-contained, reusable skill types with ``create_resource()`` and
``create_script()`` factory methods.
- **Custom sources** — any :class:`SkillsSource` implementation that provides
skills from arbitrary origins (REST APIs, databases, etc.).
@@ -573,65 +570,6 @@ def _validate_skill_description(name: str, description: str) -> None:
)
def _build_skill_content(
name: str,
description: str,
instructions: str,
resources: Sequence[SkillResource] | None = None,
scripts: Sequence[SkillScript] | None = None,
) -> str:
"""Build XML-structured content for code-defined and class-based skills.
Produces an XML document containing name, description, instructions,
resources, and scripts elements. Used by both :class:`InlineSkill`
and :class:`ClassSkill` to generate their ``content`` property.
Args:
name: The skill name.
description: The skill description.
instructions: The raw instructions text.
resources: Optional resources associated with the skill.
scripts: Optional scripts associated with the skill.
Returns:
An XML-structured content string.
"""
result = (
f"<name>{xml_escape(name)}</name>\n"
f"<description>{xml_escape(description)}</description>\n"
"\n"
"<instructions>\n"
f"{instructions}\n"
"</instructions>"
)
if resources:
resource_lines = "\n".join(_create_resource_element(r) for r in resources)
result += f"\n\n<resources>\n{resource_lines}\n</resources>"
if scripts:
script_lines = "\n".join(_create_script_element(s) for s in scripts)
result += f"\n\n<scripts>\n{script_lines}\n</scripts>"
return result
def _create_resource_element(resource: SkillResource) -> str:
"""Create a self-closing ``<resource …/>`` XML element from a :class:`SkillResource`.
Args:
resource: The resource to create the element from.
Returns:
A single indented XML element string with ``name`` and optional
``description`` attributes.
"""
attrs = f'name="{xml_escape(resource.name, quote=True)}"'
if resource.description:
attrs += f' description="{xml_escape(resource.description, quote=True)}"'
return f" <resource {attrs}/>"
@experimental(feature_id=ExperimentalFeature.SKILLS)
class InlineSkill(Skill):
"""A skill defined entirely in code with resources and scripts.
@@ -696,10 +634,25 @@ class InlineSkill(Skill):
if self._cached_content is not None:
return self._cached_content
self._cached_content = _build_skill_content(
self.name, self.description, self.instructions, self._resources, self._scripts
result = (
f"<name>{xml_escape(self.name)}</name>\n"
f"<description>{xml_escape(self.description)}</description>\n"
"\n"
"<instructions>\n"
f"{self.instructions}\n"
"</instructions>"
)
return self._cached_content
if self._resources:
resource_lines = "\n".join(self._create_resource_element(r) for r in self._resources)
result += f"\n\n<resources>\n{resource_lines}\n</resources>"
if self._scripts:
script_lines = "\n".join(_create_script_element(s) for s in self._scripts)
result += f"\n\n<scripts>\n{script_lines}\n</scripts>"
self._cached_content = result
return result
@property
def resources(self) -> list[SkillResource]:
@@ -711,6 +664,22 @@ class InlineSkill(Skill):
"""Mutable list of :class:`SkillScript` instances."""
return self._scripts
@staticmethod
def _create_resource_element(resource: SkillResource) -> str:
"""Create a self-closing ``<resource …/>`` XML element from an :class:`SkillResource`.
Args:
resource: The resource to create the element from.
Returns:
A single indented XML element string with ``name`` and optional
``description`` attributes.
"""
attrs = f'name="{xml_escape(resource.name, quote=True)}"'
if resource.description:
attrs += f' description="{xml_escape(resource.description, quote=True)}"'
return f" <resource {attrs}/>"
def resource(
self,
func: Callable[..., Any] | None = None,
@@ -731,7 +700,8 @@ class InlineSkill(Skill):
Keyword Args:
name: Resource name override. Defaults to ``func.__name__``.
description: Resource description override. Defaults to ``None``.
description: Resource description override. Defaults to the
function's docstring (via :func:`inspect.getdoc`).
Returns:
The original function unchanged, or a secondary decorator when
@@ -757,7 +727,7 @@ class InlineSkill(Skill):
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
resource_name = name or f.__name__
resource_description = description
resource_description = description or (inspect.getdoc(f) or None)
self._resources.append(
InlineSkillResource(
name=resource_name,
@@ -791,7 +761,8 @@ class InlineSkill(Skill):
Keyword Args:
name: Script name override. Defaults to ``func.__name__``.
description: Script description override. Defaults to ``None``.
description: Script description override. Defaults to the
function's docstring (via :func:`inspect.getdoc`).
Returns:
The original function unchanged, or a secondary decorator when
@@ -818,7 +789,7 @@ class InlineSkill(Skill):
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
script_name = name or f.__name__
script_description = description
script_description = description or (inspect.getdoc(f) or None)
self._scripts.append(
InlineSkillScript(
name=script_name,
@@ -833,418 +804,6 @@ class InlineSkill(Skill):
return decorator(func)
def _make_method_name(method_name: str) -> str:
"""Convert a Python method name to a skill resource/script name.
Replaces underscores with hyphens to match the skill naming convention.
Args:
method_name: The Python method name (e.g. ``"conversion_table"``).
Returns:
The converted name (e.g. ``"conversion-table"``).
"""
return method_name.replace("_", "-").strip("-")
def _validate_member_name(name: str, kind: str) -> None:
"""Validate a resource or script name at decoration time.
Args:
name: The name to validate.
kind: ``"resource"`` or ``"script"`` — used in error messages.
Raises:
ValueError: If the name is empty, too long, or contains invalid characters.
"""
if not name or not name.strip():
raise ValueError(f"@ClassSkill.{kind} name cannot be empty.")
if len(name) > MAX_NAME_LENGTH or not VALID_NAME_RE.match(name):
raise ValueError(
f"Invalid @ClassSkill.{kind} name '{name}': Must be {MAX_NAME_LENGTH} characters or fewer, "
"using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen "
"or contain consecutive hyphens."
)
def _discover_marked_members(cls: type, marker_attr: str) -> list[tuple[str, dict[str, Any]]]:
"""Scan a class for methods or properties stamped with a marker attribute.
Checks both regular callable attributes (via ``dir``) and ``property``
descriptors (via ``cls.__dict__``) whose ``fget`` carries the marker.
Args:
cls: The class to scan.
marker_attr: The marker attribute name to look for (e.g.
``"_skill_resource_marker"``).
Returns:
A list of ``(member_name, marker_dict)`` tuples.
"""
results: list[tuple[str, dict[str, Any]]] = []
seen: set[str] = set()
# Walk the MRO so that property-resources defined on a parent class
# are also discovered. ``cls.__dict__`` only sees the leaf class.
for klass in cls.__mro__:
for attr_name, attr_value in klass.__dict__.items():
if attr_name in seen:
continue
if (
isinstance(attr_value, property)
and attr_value.fget is not None
and hasattr(attr_value.fget, marker_attr)
):
results.append((attr_name, getattr(attr_value.fget, marker_attr)))
seen.add(attr_name)
# Check regular callable attributes.
for attr_name in dir(cls):
if attr_name in seen:
continue
try:
attr = getattr(cls, attr_name, None)
except Exception:
# Some descriptors (e.g. abstract properties) may raise on access.
logger.warning("Skipping '%s' during skill discovery: descriptor raised on access", attr_name)
attr = None
if attr is not None and callable(attr) and hasattr(attr, marker_attr):
results.append((attr_name, getattr(attr, marker_attr)))
return results
@experimental(feature_id=ExperimentalFeature.SKILLS)
class ClassSkill(Skill, ABC):
"""Abstract base class for defining skills as reusable Python classes.
Inherit from this class to create a self-contained skill definition.
Override :attr:`instructions` to provide the skill body.
Resources and scripts can be defined in two ways:
- **Decorator-based (recommended):** Mark methods with
:meth:`ClassSkill.resource` and :meth:`ClassSkill.script` decorators
for automatic discovery.
- **Explicit override:** Override the :attr:`resources` and
:attr:`scripts` properties, constructing :class:`InlineSkillResource`
and :class:`InlineSkillScript` instances directly.
Class-based skills can be distributed via shared libraries or PyPI
packages, making them easy to reuse across projects.
Attributes:
name: Skill name (lowercase letters, numbers, hyphens only).
description: Human-readable description of the skill.
Examples:
Decorator-based (recommended):
.. code-block:: python
class UnitConverterSkill(ClassSkill):
def __init__(self) -> None:
super().__init__(
name="unit-converter",
description="Convert between common units.",
)
@property
def instructions(self) -> str:
return "Use this skill to convert units..."
@ClassSkill.resource(name="table")
def conversion_table(self) -> str:
return "| From | To | Factor |..."
@ClassSkill.script(name="convert")
def convert(self, value: float, factor: float) -> str:
return json.dumps({"result": round(value * factor, 4)})
Explicit override:
.. code-block:: python
class UnitConverterSkill(ClassSkill):
def __init__(self) -> None:
super().__init__(
name="unit-converter",
description="Convert between common units.",
)
@property
def instructions(self) -> str:
return "Use this skill to convert units..."
@property
def resources(self) -> list[SkillResource]:
return [
InlineSkillResource(name="table", content="| From | To | Factor |..."),
]
@property
def scripts(self) -> list[SkillScript]:
return [InlineSkillScript(name="convert", function=convert_fn)]
"""
def __init__(
self,
*,
name: str,
description: str,
) -> None:
"""Initialize a ClassSkill.
Args:
name: Skill name (lowercase letters, numbers, hyphens only;
max 64 characters).
description: Human-readable description of the skill
(≤1024 characters).
"""
super().__init__(name=name, description=description)
self._cached_content: str | None = None
self._cached_resources: list[SkillResource] | None = None
self._cached_scripts: list[SkillScript] | None = None
@staticmethod
def resource(
func: Callable[..., Any] | None = None,
*,
name: str | None = None,
description: str | None = None,
) -> Any:
"""Decorator that marks a method or property as a skill resource for auto-discovery.
When applied to a method or property on a :class:`ClassSkill` subclass,
it is automatically discovered and registered as an
:class:`InlineSkillResource`. Methods are invoked each time the
resource is read. Properties are evaluated via their getter.
Can be applied to a method directly, or stacked with ``@property``
(place ``@property`` first, ``@ClassSkill.resource`` second).
Supports bare usage (``@ClassSkill.resource``) and parameterized usage
(``@ClassSkill.resource(name="custom", description="...")``).
Args:
func: The function being decorated. Populated automatically when
the decorator is applied without parentheses.
Keyword Args:
name: Resource name override. Defaults to the method name with
underscores replaced by hyphens.
description: Resource description. Defaults to ``None``.
Examples:
On a method:
.. code-block:: python
@ClassSkill.resource(name="conversion-table")
def get_table(self) -> str:
return "..."
On a property:
.. code-block:: python
@property
@ClassSkill.resource
def conversion_table(self) -> str:
return "..."
"""
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
if isinstance(f, (property, classmethod, staticmethod)):
raise TypeError(
"@ClassSkill.resource must be applied before @property, @classmethod, or @staticmethod. "
"Place @property first, then @ClassSkill.resource."
)
if name is not None:
_validate_member_name(name, "resource")
f._skill_resource_marker = { # type: ignore[attr-defined]
"name": name,
"description": description,
}
return f
if func is None:
return decorator
return decorator(func)
@staticmethod
def script(
func: Callable[..., Any] | None = None,
*,
name: str | None = None,
description: str | None = None,
) -> Any:
"""Decorator that marks a method as a skill script for auto-discovery.
When applied to a method on a :class:`ClassSkill` subclass, the method is
automatically discovered and registered as an :class:`InlineSkillScript`.
The method's parameters (excluding ``self``) are used to generate a JSON
schema, and the method is invoked in-process when the script is run.
Supports bare usage (``@ClassSkill.script``) and parameterized usage
(``@ClassSkill.script(name="custom", description="...")``).
Args:
func: The function being decorated. Populated automatically when
the decorator is applied without parentheses.
Keyword Args:
name: Script name override. Defaults to the method name with
underscores replaced by hyphens.
description: Script description. Defaults to ``None``.
Examples:
.. code-block:: python
@ClassSkill.script(name="convert")
def convert(self, value: float, factor: float) -> str:
return json.dumps({"result": round(value * factor, 4)})
"""
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
if isinstance(f, (property, classmethod, staticmethod)):
raise TypeError("@ClassSkill.script must be applied before @property, @classmethod, or @staticmethod.")
if name is not None:
_validate_member_name(name, "script")
f._skill_script_marker = { # type: ignore[attr-defined]
"name": name,
"description": description,
}
return f
if func is None:
return decorator
return decorator(func)
@property
@abstractmethod
def instructions(self) -> str:
"""The raw instructions text for this skill.
Subclasses must override this property to provide the skill body.
"""
...
@property
def resources(self) -> list[SkillResource]:
"""Resources discovered from :meth:`ClassSkill.resource`-decorated methods.
On first access, scans the class for methods marked with the
:meth:`ClassSkill.resource` decorator and instantiates
:class:`InlineSkillResource` instances from them.
The result is cached after the first access.
Override this property to provide resources explicitly instead of
using decorator-based discovery.
"""
if self._cached_resources is not None:
return list(self._cached_resources)
resources: list[SkillResource] = []
seen_names: set[str] = set()
for attr_name, attr in _discover_marked_members(type(self), "_skill_resource_marker"):
marker: dict[str, Any] = attr
resource_name = marker.get("name") or _make_method_name(attr_name)
if resource_name in seen_names:
raise ValueError(
f"Skill '{self.name}' already has a resource named '{resource_name}'. "
"Ensure each @ClassSkill.resource has a unique name."
)
seen_names.add(resource_name)
# Use inspect.getattr_static to check the descriptor type without
# triggering it, and walk the MRO so inherited properties are found.
static_attr = inspect.getattr_static(self, attr_name, None)
is_property = isinstance(static_attr, property)
resource_description = marker.get("description")
if is_property:
# Property — use a lambda that reads the property value each time.
# We capture attr_name to avoid late-binding issues.
# Do NOT call getattr here to avoid triggering the getter during discovery.
resource_func = (lambda name: lambda: getattr(self, name))(attr_name)
resources.append(
InlineSkillResource(
name=resource_name,
function=resource_func,
description=resource_description,
)
)
else:
# Regular method — use the bound method directly.
bound_method = getattr(self, attr_name)
resources.append(
InlineSkillResource(
name=resource_name,
function=bound_method,
description=resource_description,
)
)
self._cached_resources = resources
return list(self._cached_resources)
@property
def scripts(self) -> list[SkillScript]:
"""Scripts discovered from :meth:`ClassSkill.script`-decorated methods.
On first access, scans the class for methods marked with the
:meth:`ClassSkill.script` decorator and instantiates
:class:`InlineSkillScript` instances from them.
The result is cached after the first access.
Override this property to provide scripts explicitly instead of
using decorator-based discovery.
"""
if self._cached_scripts is not None:
return list(self._cached_scripts)
scripts: list[SkillScript] = []
seen_names: set[str] = set()
for attr_name, attr in _discover_marked_members(type(self), "_skill_script_marker"):
marker: dict[str, Any] = attr
script_name = marker.get("name") or _make_method_name(attr_name)
if script_name in seen_names:
raise ValueError(
f"Skill '{self.name}' already has a script named '{script_name}'. "
"Ensure each @ClassSkill.script has a unique name."
)
seen_names.add(script_name)
bound_method = getattr(self, attr_name)
script_description = marker.get("description")
scripts.append(
InlineSkillScript(
name=script_name,
function=bound_method,
description=script_description,
)
)
self._cached_scripts = scripts
return list(self._cached_scripts)
@property
def content(self) -> str:
"""Synthesized XML content containing name, description, instructions, resources, and scripts.
The result is cached after the first access.
"""
if self._cached_content is not None:
return self._cached_content
self._cached_content = _build_skill_content(
self.name, self.description, self.instructions, self.resources, self.scripts
)
return self._cached_content
@experimental(feature_id=ExperimentalFeature.SKILLS)
class FileSkill(Skill):
"""A :class:`Skill` discovered from a filesystem directory backed by a SKILL.md file.
+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.3.0"
version = "1.2.2"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
-280
View File
@@ -1,10 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
# type: ignore[reportPrivateUsage]
import asyncio
import json
import logging
import os
import sys
from contextlib import _AsyncGeneratorContextManager # type: ignore
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
@@ -29,7 +27,6 @@ from agent_framework._mcp import (
_build_prefixed_mcp_name,
_get_input_model_from_mcp_prompt,
_normalize_mcp_name,
_should_propagate_cancelled_error,
logger,
)
from agent_framework._middleware import FunctionMiddlewarePipeline
@@ -2179,7 +2176,6 @@ async def test_connect_session_creation_failure():
await tool.connect()
assert "Failed to create MCP session" in str(exc_info.value)
assert "Session creation failed" in str(exc_info.value) # exception text is now part of the message
assert "Session creation failed" in str(exc_info.value.__cause__)
@@ -2268,282 +2264,6 @@ async def test_connect_cleanup_on_initialization_failure():
tool._exit_stack.aclose.assert_called_once()
async def test_connect_cancelled_error_during_transport_creation_raises_tool_exception():
"""Test that CancelledError from transport creation is wrapped in ToolException."""
tool = MCPStreamableHTTPTool(name="test", url="http://example.com")
tool._exit_stack.aclose = AsyncMock()
tool.get_mcp_client = Mock(side_effect=asyncio.CancelledError("cancel scope"))
with pytest.raises(ToolException, match="Failed to connect to MCP server"):
await tool.connect()
tool._exit_stack.aclose.assert_called_once()
async def test_connect_cancelled_error_during_transport_creation_stdio_raises_tool_exception():
"""Test that CancelledError from transport creation uses the command-specific message for MCPStdioTool."""
tool = MCPStdioTool(name="test", command="my-server")
tool._exit_stack.aclose = AsyncMock()
tool.get_mcp_client = Mock(side_effect=asyncio.CancelledError("cancel scope"))
with pytest.raises(ToolException, match="Failed to start MCP server 'my-server'"):
await tool.connect()
tool._exit_stack.aclose.assert_called_once()
async def test_connect_cancelled_error_during_session_creation_raises_tool_exception():
"""Test that CancelledError from session creation is wrapped in ToolException."""
tool = MCPStreamableHTTPTool(name="test", url="http://example.com")
mock_transport = (Mock(), Mock())
mock_context_manager = Mock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
tool.get_mcp_client = Mock(return_value=mock_context_manager)
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session_class.return_value.__aenter__ = AsyncMock(side_effect=asyncio.CancelledError("cancel scope"))
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(ToolException, match="Failed to create MCP session"):
await tool.connect()
async def test_connect_cancelled_error_during_initialize_raises_tool_exception():
"""Test that CancelledError from session.initialize() is wrapped in ToolException.
This is the primary regression test for the bug: when an MCP server is unreachable,
the MCP library raises asyncio.CancelledError internally, which previously escaped
all except Exception handlers and could not be caught by user code.
"""
tool = MCPStreamableHTTPTool(name="test", url="http://example.com")
mock_transport = (Mock(), Mock())
mock_context_manager = Mock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
tool.get_mcp_client = Mock(return_value=mock_context_manager)
mock_session = Mock()
mock_session.initialize = AsyncMock(side_effect=asyncio.CancelledError("Cancelled via cancel scope"))
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(ToolException, match="MCP server failed to initialize"):
await tool.connect()
async def test_connect_cancelled_error_during_initialize_stdio_raises_tool_exception():
"""Test that CancelledError from session.initialize() uses the command-specific message for MCPStdioTool."""
tool = MCPStdioTool(name="test", command="my-server", args=["--port", "8080"])
mock_transport = (Mock(), Mock())
mock_context_manager = Mock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
tool.get_mcp_client = Mock(return_value=mock_context_manager)
mock_session = Mock()
mock_session.initialize = AsyncMock(side_effect=asyncio.CancelledError("Cancelled via cancel scope"))
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(ToolException, match="MCP server 'my-server --port 8080' failed to initialize"):
await tool.connect()
@pytest.mark.skipif(sys.version_info < (3, 11), reason="task.cancelling() requires Python >= 3.11")
async def test_connect_genuine_cancellation_during_transport_creation_propagates():
"""Test that genuine task cancellation (task.cancelling() > 0) propagates as CancelledError."""
tool = MCPStreamableHTTPTool(name="test", url="http://example.com")
tool._exit_stack.aclose = AsyncMock()
mock_cancelled_task = Mock()
mock_cancelled_task.cancelling.return_value = 1
with patch("asyncio.current_task", return_value=mock_cancelled_task):
tool.get_mcp_client = Mock(side_effect=asyncio.CancelledError("task cancelled"))
with pytest.raises(asyncio.CancelledError):
await tool.connect()
tool._exit_stack.aclose.assert_called_once()
@pytest.mark.skipif(sys.version_info < (3, 11), reason="task.cancelling() requires Python >= 3.11")
async def test_connect_genuine_cancellation_during_initialize_propagates():
"""Test that genuine task cancellation during initialize() propagates as CancelledError."""
tool = MCPStreamableHTTPTool(name="test", url="http://example.com")
tool._exit_stack.aclose = AsyncMock()
mock_transport = (Mock(), Mock())
mock_context_manager = Mock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
tool.get_mcp_client = Mock(return_value=mock_context_manager)
mock_session = Mock()
mock_session.initialize = AsyncMock(side_effect=asyncio.CancelledError("task cancelled"))
mock_cancelled_task = Mock()
mock_cancelled_task.cancelling.return_value = 1
with (
patch("asyncio.current_task", return_value=mock_cancelled_task),
patch("mcp.client.session.ClientSession") as mock_session_class,
):
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(asyncio.CancelledError):
await tool.connect()
tool._exit_stack.aclose.assert_called_once()
@pytest.mark.skipif(sys.version_info < (3, 11), reason="task.cancelling() requires Python >= 3.11")
async def test_connect_genuine_cancellation_during_session_creation_propagates():
"""Test that genuine task cancellation during session creation propagates as CancelledError."""
tool = MCPStreamableHTTPTool(name="test", url="http://example.com")
tool._exit_stack.aclose = AsyncMock()
mock_transport = (Mock(), Mock())
mock_context_manager = Mock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
tool.get_mcp_client = Mock(return_value=mock_context_manager)
mock_cancelled_task = Mock()
mock_cancelled_task.cancelling.return_value = 1
with (
patch("asyncio.current_task", return_value=mock_cancelled_task),
patch("mcp.client.session.ClientSession") as mock_session_class,
):
mock_session_class.return_value.__aenter__ = AsyncMock(side_effect=asyncio.CancelledError("task cancelled"))
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(asyncio.CancelledError):
await tool.connect()
tool._exit_stack.aclose.assert_called_once()
async def test_aenter_cancelled_error_during_connect_is_catchable_as_exception():
"""Test that CancelledError during __aenter__ is catchable as Exception.
Verifies the end-to-end fix: async with MCPStreamableHTTPTool(...) raises an
exception that can be caught by a normal `except Exception` block.
"""
tool = MCPStreamableHTTPTool(name="test", url="http://example.com")
mock_session = Mock()
mock_session.initialize = AsyncMock(side_effect=asyncio.CancelledError("Cancelled via cancel scope"))
mock_transport = (Mock(), Mock())
mock_context_manager = Mock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
tool.get_mcp_client = Mock(return_value=mock_context_manager)
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
caught = None
try:
async with tool:
pass
except Exception as e:
caught = e
assert caught is not None, "Expected an exception to be caught by except Exception"
assert isinstance(caught, ToolException)
# Tests for _should_propagate_cancelled_error helper
def test_should_propagate_cancelled_error_returns_false_for_non_cancelled_error():
assert _should_propagate_cancelled_error(RuntimeError("boom")) is False
def test_should_propagate_cancelled_error_returns_false_when_no_current_task():
with patch("asyncio.current_task", return_value=None):
assert _should_propagate_cancelled_error(asyncio.CancelledError()) is False
@pytest.mark.skipif(sys.version_info < (3, 11), reason="task.cancelling() requires Python >= 3.11")
def test_should_propagate_cancelled_error_returns_true_when_task_is_cancelling():
mock_task = Mock()
mock_task.cancelling.return_value = 1
with patch("asyncio.current_task", return_value=mock_task):
assert _should_propagate_cancelled_error(asyncio.CancelledError()) is True
@pytest.mark.skipif(sys.version_info < (3, 11), reason="task.cancelling() requires Python >= 3.11")
def test_should_propagate_cancelled_error_returns_false_when_task_not_cancelling():
mock_task = Mock()
mock_task.cancelling.return_value = 0
with patch("asyncio.current_task", return_value=mock_task):
assert _should_propagate_cancelled_error(asyncio.CancelledError()) is False
async def test_connect_cancelled_error_during_session_creation_includes_exception_in_message():
"""Test that CancelledError from session creation includes exception details in ToolException message."""
tool = MCPStreamableHTTPTool(name="test", url="http://example.com")
mock_transport = (Mock(), Mock())
mock_context_manager = Mock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
tool.get_mcp_client = Mock(return_value=mock_context_manager)
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session_class.return_value.__aenter__ = AsyncMock(
side_effect=asyncio.CancelledError("cancel scope detail")
)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(ToolException) as exc_info:
await tool.connect()
assert "Failed to create MCP session" in str(exc_info.value)
assert "cancel scope detail" in str(exc_info.value)
async def test_connect_cancelled_error_during_session_creation_logs_with_exc_info():
"""Test that CancelledError from session creation is logged with exc_info=True."""
tool = MCPStreamableHTTPTool(name="test", url="http://example.com")
mock_transport = (Mock(), Mock())
mock_context_manager = Mock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
tool.get_mcp_client = Mock(return_value=mock_context_manager)
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session_class.return_value.__aenter__ = AsyncMock(side_effect=asyncio.CancelledError("cancel scope"))
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
from agent_framework._mcp import logger as mcp_logger
with patch.object(mcp_logger, "debug") as mock_debug:
with pytest.raises(ToolException):
await tool.connect()
# Verify logger.debug was called with exc_info=True (not an exception instance)
debug_calls = mock_debug.call_args_list
cancel_calls = [c for c in debug_calls if "Failed to create MCP session" in str(c)]
assert cancel_calls, "Expected a debug log for the cancelled session creation"
_, kwargs = cancel_calls[0]
assert kwargs.get("exc_info") is True
def test_mcp_stdio_tool_get_mcp_client_with_env_and_kwargs():
"""Test MCPStdioTool.get_mcp_client() with environment variables and client kwargs."""
env_vars = {"PATH": "/usr/bin", "DEBUG": "1"}
+8 -762
View File
@@ -5,7 +5,6 @@
from __future__ import annotations
import os
from abc import ABC
from collections.abc import Sequence
from pathlib import Path
from typing import Any
@@ -15,7 +14,6 @@ import pytest
from agent_framework import (
AggregatingSkillsSource,
ClassSkill,
DeduplicatingSkillsSource,
FileSkill,
FileSkillScript,
@@ -34,7 +32,6 @@ from agent_framework._skills import (
DEFAULT_SCRIPT_EXTENSIONS,
InlineSkillResource,
InlineSkillScript,
_create_resource_element,
_create_script_element,
_FileSkillResource,
)
@@ -1007,7 +1004,7 @@ class TestInlineSkill:
assert len(skill.resources) == 1
assert skill.resources[0].name == "get_schema"
assert skill.resources[0].description is None
assert skill.resources[0].description == "Get the database schema."
assert isinstance(skill.resources[0], InlineSkillResource)
assert skill.resources[0].function is get_schema
@@ -1680,22 +1677,22 @@ class TestCreateResourceElement:
def test_name_only(self) -> None:
r = InlineSkillResource(name="my-ref", content="data")
elem = _create_resource_element(r)
elem = InlineSkill._create_resource_element(r)
assert elem == ' <resource name="my-ref"/>'
def test_with_description(self) -> None:
r = InlineSkillResource(name="my-ref", description="A reference.", content="data")
elem = _create_resource_element(r)
elem = InlineSkill._create_resource_element(r)
assert elem == ' <resource name="my-ref" description="A reference."/>'
def test_xml_escapes_name(self) -> None:
r = InlineSkillResource(name='ref"special', content="data")
elem = _create_resource_element(r)
elem = InlineSkill._create_resource_element(r)
assert "&quot;" in elem
def test_xml_escapes_description(self) -> None:
r = InlineSkillResource(name="ref", description='Uses <tags> & "quotes"', content="data")
elem = _create_resource_element(r)
elem = InlineSkill._create_resource_element(r)
assert "&lt;tags&gt;" in elem
assert "&amp;" in elem
assert "&quot;" in elem
@@ -2139,8 +2136,8 @@ class TestSkillResourceDecoratorEdgeCases:
return "data"
assert skill.resources[0].name == "custom-name"
# description is None when not explicitly provided
assert skill.resources[0].description is None
# description falls back to docstring
assert skill.resources[0].description == "Some docs."
def test_decorator_with_description_only(self) -> None:
skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@@ -2323,7 +2320,7 @@ class TestSkillScriptDecorator:
assert len(skill.scripts) == 1
assert skill.scripts[0].name == "analyze"
assert skill.scripts[0].description is None
assert skill.scripts[0].description == "Run analysis."
assert isinstance(skill.scripts[0], InlineSkillScript)
assert skill.scripts[0].function is analyze
@@ -3180,757 +3177,6 @@ class TestLoadSkillWithScripts:
result = provider._load_skill(_raw_skills(provider), "my-skill")
assert "<scripts>" not in result
# ---------------------------------------------------------------------------
# Tests: ClassSkill
# ---------------------------------------------------------------------------
class _MinimalClassSkill(ClassSkill):
"""A minimal class-based skill with no resources or scripts."""
def __init__(self) -> None:
super().__init__(name="minimal-skill", description="A minimal skill.")
@property
def instructions(self) -> str:
return "Do minimal things."
class _FullClassSkill(ClassSkill):
"""A class-based skill with resources and scripts."""
def __init__(self) -> None:
super().__init__(name="full-skill", description="A full skill.")
self._resources: list[SkillResource] | None = None
self._scripts: list[SkillScript] | None = None
@property
def instructions(self) -> str:
return "Use this skill for full tasks."
@property
def resources(self) -> list[SkillResource]:
if self._resources is None:
self._resources = [
InlineSkillResource(name="test-resource", content="Static resource content."),
]
return self._resources
@property
def scripts(self) -> list[SkillScript]:
if self._scripts is None:
self._scripts = [
InlineSkillScript(name="test-script", function=_class_skill_test_fn),
]
return self._scripts
def _class_skill_test_fn(value: float, factor: float) -> str:
"""Multiply value by factor."""
import json as _json
return _json.dumps({"result": round(value * factor, 4)})
class TestClassSkill:
"""Tests for ClassSkill abstract base class."""
def test_minimal_skill_has_no_resources(self) -> None:
skill = _MinimalClassSkill()
assert skill.resources == []
def test_minimal_skill_has_no_scripts(self) -> None:
skill = _MinimalClassSkill()
assert skill.scripts == []
def test_minimal_skill_content_contains_name(self) -> None:
skill = _MinimalClassSkill()
assert "<name>minimal-skill</name>" in skill.content
def test_minimal_skill_content_contains_description(self) -> None:
skill = _MinimalClassSkill()
assert "<description>A minimal skill.</description>" in skill.content
def test_minimal_skill_content_contains_instructions(self) -> None:
skill = _MinimalClassSkill()
assert "Do minimal things." in skill.content
def test_minimal_skill_content_no_resources_element(self) -> None:
skill = _MinimalClassSkill()
assert "<resources>" not in skill.content
def test_minimal_skill_content_no_scripts_element(self) -> None:
skill = _MinimalClassSkill()
assert "<scripts>" not in skill.content
def test_full_skill_has_resources(self) -> None:
skill = _FullClassSkill()
assert len(skill.resources) == 1
assert skill.resources[0].name == "test-resource"
def test_full_skill_has_scripts(self) -> None:
skill = _FullClassSkill()
assert len(skill.scripts) == 1
assert skill.scripts[0].name == "test-script"
def test_full_skill_content_contains_resources(self) -> None:
skill = _FullClassSkill()
assert "<resources>" in skill.content
assert 'name="test-resource"' in skill.content
def test_full_skill_content_contains_scripts(self) -> None:
skill = _FullClassSkill()
assert "<scripts>" in skill.content
assert 'name="test-script"' in skill.content
def test_content_is_cached(self) -> None:
skill = _MinimalClassSkill()
content1 = skill.content
content2 = skill.content
assert content1 is content2
def test_resources_are_lazy_cached(self) -> None:
skill = _FullClassSkill()
resources1 = skill.resources
resources2 = skill.resources
assert resources1 is resources2
def test_scripts_are_lazy_cached(self) -> None:
skill = _FullClassSkill()
scripts1 = skill.scripts
scripts2 = skill.scripts
assert scripts1 is scripts2
def test_script_has_parameters_schema(self) -> None:
skill = _FullClassSkill()
script = skill.scripts[0]
assert isinstance(script, InlineSkillScript)
schema = script.parameters_schema
assert schema is not None
assert "value" in schema.get("properties", {})
assert "factor" in schema.get("properties", {})
async def test_provider_with_class_skill(self) -> None:
skill = _FullClassSkill()
provider = SkillsProvider([skill])
await _init_provider(provider)
skills = _raw_skills(provider)
assert len(skills) == 1
assert skills[0].name == "full-skill"
async def test_provider_loads_class_skill_content(self) -> None:
skill = _FullClassSkill()
provider = SkillsProvider([skill])
await _init_provider(provider)
result = provider._load_skill(_raw_skills(provider), "full-skill")
assert "Use this skill for full tasks." in result
assert "<resources>" in result
assert "<scripts>" in result
async def test_in_memory_source_with_class_skill(self) -> None:
skill = _MinimalClassSkill()
source = InMemorySkillsSource([skill])
skills = await source.get_skills()
assert len(skills) == 1
assert skills[0].name == "minimal-skill"
async def test_mixed_inline_and_class_skills(self) -> None:
inline = InlineSkill(name="inline-skill", description="Inline", instructions="inline body")
class_skill = _MinimalClassSkill()
provider = SkillsProvider([inline, class_skill])
await _init_provider(provider)
skills = _raw_skills(provider)
names = {s.name for s in skills}
assert names == {"inline-skill", "minimal-skill"}
async def test_class_skill_script_runs(self) -> None:
skill = _FullClassSkill()
script = skill.scripts[0]
result = await script.run(skill, {"value": 10.0, "factor": 2.5})
import json as _json
parsed = _json.loads(result)
assert parsed["result"] == 25.0
async def test_class_skill_resource_reads(self) -> None:
skill = _FullClassSkill()
resource = skill.resources[0]
content = await resource.read()
assert content == "Static resource content."
# ---------------------------------------------------------------------------
# Tests: ClassSkill with decorator-based discovery
# ---------------------------------------------------------------------------
class _DecoratorClassSkill(ClassSkill):
"""A class-based skill using @ClassSkill.resource and @ClassSkill.script decorators."""
def __init__(self) -> None:
super().__init__(name="decorator-skill", description="A decorator-discovered skill.")
@property
def instructions(self) -> str:
return "Use this skill for decorator tests."
@ClassSkill.resource(name="lookup-table")
def get_table(self) -> str:
"""Conversion lookup table."""
return "| From | To | Factor |"
@ClassSkill.script(name="convert")
def run_convert(self, value: float, factor: float) -> str:
"""Convert a value."""
import json as _json
return _json.dumps({"result": round(value * factor, 4)})
class _BareDecoratorSkill(ClassSkill):
"""Skill using bare decorators (no arguments) — name/description from method."""
def __init__(self) -> None:
super().__init__(name="bare-skill", description="Bare decorator skill.")
@property
def instructions(self) -> str:
return "Bare instructions."
@ClassSkill.resource
def my_table(self) -> str:
"""The table docs."""
return "table content"
@ClassSkill.script
def my_script(self, x: int) -> int:
"""Double x."""
return x * 2
class _DuplicateResourceSkill(ClassSkill):
"""Skill with duplicate resource names — should raise."""
def __init__(self) -> None:
super().__init__(name="dup-skill", description="Dup.")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.resource(name="same-name")
def res_a(self) -> str:
return "a"
@ClassSkill.resource(name="same-name")
def res_b(self) -> str:
return "b"
class _DuplicateScriptSkill(ClassSkill):
"""Skill with duplicate script names — should raise."""
def __init__(self) -> None:
super().__init__(name="dup-script-skill", description="Dup.")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.script(name="same-name")
def script_a(self, x: int) -> int:
return x
@ClassSkill.script(name="same-name")
def script_b(self, x: int) -> int:
return x
class _SelfAccessSkill(ClassSkill):
"""Skill where resource/script access instance state via self."""
def __init__(self, multiplier: int = 10) -> None:
super().__init__(name="self-access", description="Self access skill.")
self.multiplier = multiplier
@property
def instructions(self) -> str:
return "Use multiplier."
@ClassSkill.resource(name="config")
def get_config(self) -> str:
return f"multiplier={self.multiplier}"
@ClassSkill.script(name="multiply")
def multiply(self, value: int) -> int:
return value * self.multiplier
class TestClassSkillDecoratorDiscovery:
"""Tests for decorator-based resource/script discovery on ClassSkill."""
def test_discovers_resources(self) -> None:
skill = _DecoratorClassSkill()
assert len(skill.resources) == 1
assert skill.resources[0].name == "lookup-table"
def test_discovers_scripts(self) -> None:
skill = _DecoratorClassSkill()
assert len(skill.scripts) == 1
assert skill.scripts[0].name == "convert"
def test_resource_description_from_decorator(self) -> None:
skill = _DecoratorClassSkill()
assert skill.resources[0].description is None
def test_script_description_from_decorator(self) -> None:
skill = _DecoratorClassSkill()
assert skill.scripts[0].description is None
def test_bare_decorator_name_from_method(self) -> None:
skill = _BareDecoratorSkill()
assert skill.resources[0].name == "my-table"
assert skill.scripts[0].name == "my-script"
def test_bare_decorator_description_is_none(self) -> None:
skill = _BareDecoratorSkill()
assert skill.resources[0].description is None
assert skill.scripts[0].description is None
async def test_resource_reads(self) -> None:
skill = _DecoratorClassSkill()
content = await skill.resources[0].read()
assert content == "| From | To | Factor |"
async def test_script_runs(self) -> None:
skill = _DecoratorClassSkill()
import json as _json
result = await skill.scripts[0].run(skill, {"value": 10.0, "factor": 2.5})
parsed = _json.loads(result)
assert parsed["result"] == 25.0
def test_script_schema_excludes_self(self) -> None:
skill = _DecoratorClassSkill()
script = skill.scripts[0]
assert isinstance(script, InlineSkillScript)
schema = script.parameters_schema
assert schema is not None
props = schema.get("properties", {})
assert "self" not in props
assert "value" in props
assert "factor" in props
def test_resources_cached(self) -> None:
skill = _DecoratorClassSkill()
r1 = skill.resources
r2 = skill.resources
assert r1 == r2
assert r1 is not r2 # defensive copy
def test_scripts_cached(self) -> None:
skill = _DecoratorClassSkill()
s1 = skill.scripts
s2 = skill.scripts
assert s1 == s2
assert s1 is not s2 # defensive copy
def test_content_includes_discovered_resources(self) -> None:
skill = _DecoratorClassSkill()
assert "<resources>" in skill.content
assert 'name="lookup-table"' in skill.content
def test_content_includes_discovered_scripts(self) -> None:
skill = _DecoratorClassSkill()
assert "<scripts>" in skill.content
assert 'name="convert"' in skill.content
def test_duplicate_resource_name_raises(self) -> None:
skill = _DuplicateResourceSkill()
with pytest.raises(ValueError, match="already has a resource named"):
_ = skill.resources
def test_duplicate_script_name_raises(self) -> None:
skill = _DuplicateScriptSkill()
with pytest.raises(ValueError, match="already has a script named"):
_ = skill.scripts
async def test_self_access_resource(self) -> None:
skill = _SelfAccessSkill(multiplier=42)
content = await skill.resources[0].read()
assert content == "multiplier=42"
async def test_self_access_script(self) -> None:
skill = _SelfAccessSkill(multiplier=3)
result = await skill.scripts[0].run(skill, {"value": 7})
assert result == 21
def test_no_decorators_yields_empty(self) -> None:
skill = _MinimalClassSkill()
assert skill.resources == []
assert skill.scripts == []
async def test_provider_with_decorator_skill(self) -> None:
skill = _DecoratorClassSkill()
provider = SkillsProvider([skill])
await _init_provider(provider)
skills = _raw_skills(provider)
assert len(skills) == 1
assert skills[0].name == "decorator-skill"
def test_manual_override_wins(self) -> None:
"""A subclass that overrides resources/scripts bypasses decorator discovery."""
skill = _FullClassSkill()
assert len(skill.resources) == 1
assert skill.resources[0].name == "test-resource"
async def test_property_resource_reads(self) -> None:
"""@ClassSkill.resource on a @property works correctly."""
skill = _PropertyResourceSkill()
assert len(skill.resources) == 1
assert skill.resources[0].name == "static-table"
content = await skill.resources[0].read()
assert "miles" in content
def test_property_resource_description_is_none_without_explicit(self) -> None:
skill = _PropertyResourceSkill()
assert skill.resources[0].description is None
def test_property_resource_in_content(self) -> None:
skill = _PropertyResourceSkill()
assert 'name="static-table"' in skill.content
async def test_mixed_property_and_method_resources(self) -> None:
"""Property and method resources can coexist."""
skill = _MixedPropertyMethodSkill()
names = {r.name for r in skill.resources}
assert names == {"prop-data", "method-data"}
for r in skill.resources:
content = await r.read()
assert "content" in content.lower()
def test_explicit_resource_description_in_object(self) -> None:
"""Explicit description= on @ClassSkill.resource is stored on the object."""
skill = _ExplicitDescriptionSkill()
res = next(r for r in skill.resources if r.name == "described-res")
assert res.description == "A described resource."
def test_explicit_script_description_in_object(self) -> None:
"""Explicit description= on @ClassSkill.script is stored on the object."""
skill = _ExplicitDescriptionSkill()
scr = next(s for s in skill.scripts if s.name == "described-scr")
assert scr.description == "A described script."
def test_explicit_description_in_content_xml(self) -> None:
"""Explicit descriptions appear in the skill content XML."""
skill = _ExplicitDescriptionSkill()
assert 'description="A described resource."' in skill.content
assert 'description="A described script."' in skill.content
def test_property_getter_not_called_during_discovery(self) -> None:
"""Property getter must NOT be evaluated when resources are discovered."""
skill = _PropertyCallCountSkill()
assert skill.getter_call_count == 0
_ = skill.resources # discovery should NOT call the getter
assert skill.getter_call_count == 0
async def test_property_getter_called_on_read(self) -> None:
"""Property getter IS evaluated when the resource is read."""
skill = _PropertyCallCountSkill()
_ = skill.resources
assert skill.getter_call_count == 0
await skill.resources[0].read()
assert skill.getter_call_count == 1
def test_make_method_name_strips_leading_trailing_hyphens(self) -> None:
"""_make_method_name strips leading/trailing underscores turned to hyphens."""
from agent_framework._skills import _make_method_name
assert _make_method_name("my_method") == "my-method"
assert _make_method_name("_private_method_") == "private-method"
assert _make_method_name("__dunder__") == "dunder"
assert _make_method_name("already_good") == "already-good"
def test_inherited_decorated_resources_are_discovered(self) -> None:
"""Decorated resources from a parent class are discovered on subclass."""
skill = _ChildSkill()
names = {r.name for r in skill.resources}
assert "parent-data" in names
def test_inherited_decorated_scripts_are_discovered(self) -> None:
"""Decorated scripts from a parent class are discovered on subclass."""
skill = _ChildSkill()
names = {s.name for s in skill.scripts}
assert "parent-action" in names
def test_child_can_add_own_resources(self) -> None:
"""A child class can add resources alongside inherited ones."""
skill = _ChildSkill()
names = {r.name for r in skill.resources}
assert "parent-data" in names
assert "child-data" in names
async def test_script_receives_kwargs(self) -> None:
"""ClassSkill scripts receive **kwargs forwarded from the runtime."""
skill = _KwargsSkill()
script = skill.scripts[0]
result = await script.run(skill, {"x": 5}, custom_key="hello")
assert result == "5-hello"
def test_wrong_decorator_order_resource_raises(self) -> None:
"""@ClassSkill.resource above @property raises TypeError at class definition."""
with pytest.raises(TypeError, match="must be applied before @property"):
class _BadOrder(ClassSkill):
def __init__(self) -> None:
super().__init__(name="bad", description="bad")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.resource(name="oops") # wrong: should be below @property
@property
def bad_prop(self) -> str:
return "x"
def test_wrong_decorator_order_script_raises(self) -> None:
"""@ClassSkill.script on a property raises TypeError."""
with pytest.raises(TypeError, match="must be applied before"):
class _BadOrder(ClassSkill):
def __init__(self) -> None:
super().__init__(name="bad", description="bad")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.script(name="oops")
@property
def bad_prop(self) -> str:
return "x"
def test_invalid_explicit_resource_name_raises(self) -> None:
"""Invalid name= on @ClassSkill.resource raises ValueError at decoration."""
with pytest.raises(ValueError, match="Invalid @ClassSkill.resource name"):
class _BadName(ClassSkill):
def __init__(self) -> None:
super().__init__(name="bad", description="bad")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.resource(name="UPPER CASE!")
def res(self) -> str:
return "x"
def test_invalid_explicit_script_name_raises(self) -> None:
"""Invalid name= on @ClassSkill.script raises ValueError at decoration."""
with pytest.raises(ValueError, match="Invalid @ClassSkill.script name"):
class _BadName(ClassSkill):
def __init__(self) -> None:
super().__init__(name="bad", description="bad")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.script(name="has spaces")
def scr(self, x: int) -> int:
return x
def test_empty_explicit_name_raises(self) -> None:
"""Empty name= on @ClassSkill.resource raises ValueError."""
with pytest.raises(ValueError, match="name cannot be empty"):
class _EmptyName(ClassSkill):
def __init__(self) -> None:
super().__init__(name="bad", description="bad")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.resource(name="")
def res(self) -> str:
return "x"
def test_resources_copy_prevents_cache_mutation(self) -> None:
"""Mutating the returned resources list does not affect the cache."""
skill = _DecoratorClassSkill()
r1 = skill.resources
r1.clear()
r2 = skill.resources
assert len(r2) == 1 # original cached list is intact
def test_scripts_copy_prevents_cache_mutation(self) -> None:
"""Mutating the returned scripts list does not affect the cache."""
skill = _DecoratorClassSkill()
s1 = skill.scripts
s1.clear()
s2 = skill.scripts
assert len(s2) == 1 # original cached list is intact
async def test_inherited_property_resource_discovered(self) -> None:
"""A @property @ClassSkill.resource on a parent class is discovered on child."""
skill = _ChildWithInheritedPropertySkill()
names = {r.name for r in skill.resources}
assert "parent-prop" in names
content = await next(r for r in skill.resources if r.name == "parent-prop").read()
assert content == "parent property content"
# ---------------------------------------------------------------------------
# Helper skills for additional tests
# ---------------------------------------------------------------------------
class _ExplicitDescriptionSkill(ClassSkill):
"""Skill with explicit descriptions on decorator."""
def __init__(self) -> None:
super().__init__(name="desc-skill", description="Explicit desc.")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.resource(name="described-res", description="A described resource.")
def res(self) -> str:
return "data"
@ClassSkill.script(name="described-scr", description="A described script.")
def scr(self, x: int) -> int:
return x
class _PropertyCallCountSkill(ClassSkill):
"""Tracks how many times the property getter is called."""
def __init__(self) -> None:
super().__init__(name="callcount-skill", description="Tracks calls.")
self.getter_call_count = 0
@property
def instructions(self) -> str:
return "x"
@property
@ClassSkill.resource(name="counted")
def counted_resource(self) -> str:
self.getter_call_count += 1
return "counted"
class _ParentSkill(ClassSkill, ABC):
"""Parent with decorated resources/scripts."""
@ClassSkill.resource(name="parent-data")
def parent_resource(self) -> str:
return "parent"
@ClassSkill.script(name="parent-action")
def parent_script(self, x: int) -> int:
return x
class _ChildSkill(_ParentSkill):
"""Child inheriting parent resources and adding its own."""
def __init__(self) -> None:
super().__init__(name="child-skill", description="Child.")
@property
def instructions(self) -> str:
return "child"
@ClassSkill.resource(name="child-data")
def child_resource(self) -> str:
return "child"
class _KwargsSkill(ClassSkill):
"""Skill that uses **kwargs from runtime."""
def __init__(self) -> None:
super().__init__(name="kwargs-skill", description="Kwargs.")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.script(name="echo")
def echo(self, x: int, **kwargs: Any) -> str:
return f"{x}-{kwargs.get('custom_key', 'none')}"
class _ParentWithPropertyResource(ClassSkill, ABC):
"""Parent with a property-based resource."""
@property
@ClassSkill.resource(name="parent-prop")
def parent_property(self) -> str:
return "parent property content"
class _ChildWithInheritedPropertySkill(_ParentWithPropertyResource):
"""Child that should discover inherited property resource."""
def __init__(self) -> None:
super().__init__(name="child-prop-skill", description="Child prop.")
@property
def instructions(self) -> str:
return "x"
class _PropertyResourceSkill(ClassSkill):
"""Skill with a property-based resource."""
def __init__(self) -> None:
super().__init__(name="prop-skill", description="Property skill.")
@property
def instructions(self) -> str:
return "Use this skill."
@property
@ClassSkill.resource(name="static-table")
def conversion_table(self) -> str:
"""Static conversion table."""
return "| miles | km | 1.60934 |"
class _MixedPropertyMethodSkill(ClassSkill):
"""Skill with both property and method resources."""
def __init__(self) -> None:
super().__init__(name="mixed-prop", description="Mixed.")
@property
def instructions(self) -> str:
return "x"
@property
@ClassSkill.resource(name="prop-data")
def static_data(self) -> str:
"""Static content."""
return "Property Content"
@ClassSkill.resource(name="method-data")
def dynamic_data(self) -> str:
"""Dynamic content."""
return "Method Content"
async def test_code_skill_scripts_element_contains_parameters(self) -> None:
"""Scripts XML includes parameters schema when the function has typed parameters."""
+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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"httpx>=0.27,<1",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
+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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"openai>=1.99.0,<3",
"opentelemetry-sdk>=1.39.0,<2",
"fastapi>=0.115.0,<0.133.1",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"durabletask>=1.3.0,<2",
"durabletask-azuremanaged>=1.3.0,<2",
"python-dateutil>=2.8.0,<3",
+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.3.0"
version = "1.2.2"
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.3.0,<2",
"agent-framework-openai>=1.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"agent-framework-openai>=1.2.2,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.1.0,<3.0",
]
@@ -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.0a260507"
version = "1.0.0a260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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",
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-openai>=1.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"agent-framework-openai>=1.1.0,<2",
"foundry-local-sdk>=0.5.1,<0.5.2",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260507"
version = "1.0.0a260429"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2.0",
"agent-framework-core>=1.2.2,<2.0",
"google-genai>=1.65.0,<2.0.0",
]
@@ -140,18 +140,12 @@ class GitHubCopilotSettings(TypedDict, total=False):
Can be set via environment variable GITHUB_COPILOT_TIMEOUT.
log_level: CLI log level.
Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL.
copilot_home: Directory where the CLI stores session state, configuration,
and other persistent data. Can be set via environment variable
GITHUB_COPILOT_COPILOT_HOME. Defaults to ~/.copilot when not set.
Only applicable when the SDK spawns the CLI process (ignored when
connecting to an external server via a pre-configured client).
"""
cli_path: str | None
model: str | None
timeout: float | None
log_level: str | None
copilot_home: str | None
class GitHubCopilotOptions(TypedDict, total=False):
@@ -193,12 +187,6 @@ class GitHubCopilotOptions(TypedDict, total=False):
instead of the default GitHub Copilot backend.
"""
instruction_directories: list[str]
"""Additional directories to search for custom instruction files.
Lets applications point the CLI at project-specific or team-shared instruction
files beyond the default locations.
"""
on_function_approval: FunctionApprovalCallback
"""Approval callback for ``FunctionTool`` instances declared with
``approval_mode="always_require"``. The callback is awaited (sync or async)
@@ -312,9 +300,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
on_permission_request: PermissionHandlerType | None = opts.pop("on_permission_request", None)
mcp_servers: dict[str, MCPServerConfig] | None = opts.pop("mcp_servers", None)
provider: ProviderConfig | None = opts.pop("provider", None)
instruction_directories: list[str] | None = opts.pop("instruction_directories", None)
on_function_approval: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
copilot_home = opts.pop("copilot_home", None)
self._settings = load_settings(
GitHubCopilotSettings,
@@ -323,7 +309,6 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
model=model,
timeout=timeout,
log_level=log_level,
copilot_home=copilot_home,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
@@ -333,7 +318,6 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
self._function_approval_handler: FunctionApprovalCallback | None = on_function_approval
self._mcp_servers = mcp_servers
self._provider = provider
self._instruction_directories = instruction_directories
self._default_options = opts
self._started = False
@@ -362,13 +346,10 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
if self._client is None:
cli_path = self._settings.get("cli_path") or None
log_level = self._settings.get("log_level") or None
copilot_home = self._settings.get("copilot_home") or None
subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path}
if log_level:
subprocess_kwargs["log_level"] = log_level
if copilot_home:
subprocess_kwargs["copilot_home"] = copilot_home
self._client = CopilotClient(SubprocessConfig(**subprocess_kwargs))
try:
@@ -542,14 +523,13 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
# send_and_wait returns only the final ASSISTANT_MESSAGE event;
# other events (deltas, tool calls) are handled internally by the SDK.
if response_event and response_event.type == SessionEventType.ASSISTANT_MESSAGE:
data: Any = response_event.data
message_id = data.message_id
message_id = response_event.data.message_id
if data.content:
if response_event.data.content:
response_messages.append(
Message(
role="assistant",
contents=[Content.from_text(data.content)],
contents=[Content.from_text(response_event.data.content)],
message_id=message_id,
raw_representation=response_event,
)
@@ -623,13 +603,12 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
def event_handler(event: SessionEvent) -> None:
if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA:
data: Any = event.data
if data.delta_content:
if event.data.delta_content:
update = AgentResponseUpdate(
role="assistant",
contents=[Content.from_text(data.delta_content)],
response_id=data.message_id,
message_id=data.message_id,
contents=[Content.from_text(event.data.delta_content)],
response_id=event.data.message_id,
message_id=event.data.message_id,
raw_representation=event,
)
queue.put_nowait(update)
@@ -673,8 +652,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
elif event.type == SessionEventType.SESSION_IDLE:
queue.put_nowait(None)
elif event.type == SessionEventType.SESSION_ERROR:
error_data: Any = event.data
error_msg = error_data.message or "Unknown error"
error_msg = event.data.message or "Unknown error"
queue.put_nowait(AgentException(f"GitHub Copilot session error: {error_msg}"))
unsubscribe = copilot_session.on(event_handler)
@@ -860,7 +838,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
try:
if agent_session.service_session_id:
return await self._resume_session(agent_session.service_session_id, streaming, runtime_options)
return await self._resume_session(agent_session.service_session_id, streaming)
session = await self._create_session(streaming, runtime_options)
agent_session.service_session_id = session.session_id
@@ -890,7 +868,6 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
)
mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None
provider = opts.get("provider") or self._provider or None
instruction_directories = opts.get("instruction_directories", self._instruction_directories)
tools = self._prepare_tools(self._tools) if self._tools else None
return await self._client.create_session(
@@ -901,46 +878,23 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
tools=tools or None,
mcp_servers=mcp_servers or None,
provider=provider or None,
instruction_directories=instruction_directories,
)
async def _resume_session(
self,
session_id: str,
streaming: bool,
runtime_options: dict[str, Any] | None = None,
) -> CopilotSession:
"""Resume an existing Copilot session by ID.
Args:
session_id: The session ID to resume.
streaming: Whether to enable streaming for the session.
runtime_options: Runtime options that take precedence over default_options.
"""
async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession:
"""Resume an existing Copilot session by ID."""
if not self._client:
raise RuntimeError("GitHub Copilot client not initialized. Call start() first.")
opts = runtime_options or {}
model = opts.get("model") or self._settings.get("model") or None
system_message = opts.get("system_message") or self._default_options.get("system_message") or None
permission_handler: PermissionHandlerType = (
opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions
)
mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None
provider = opts.get("provider") or self._provider or None
instruction_directories = opts.get("instruction_directories", self._instruction_directories)
permission_handler: PermissionHandlerType = self._permission_handler or _deny_all_permissions
tools = self._prepare_tools(self._tools) if self._tools else None
return await self._client.resume_session(
session_id,
on_permission_request=permission_handler,
streaming=streaming,
model=model or None,
system_message=system_message or None,
tools=tools or None,
mcp_servers=mcp_servers or None,
provider=provider or None,
instruction_directories=instruction_directories,
mcp_servers=self._mcp_servers or None,
provider=self._provider or None,
)
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
"agent-framework-core>=1.2.2,<2",
"github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'",
]
[tool.uv]
@@ -22,13 +22,7 @@ from agent_framework import (
Message,
)
from agent_framework.exceptions import AgentException
from copilot.generated.session_events import (
Data,
SessionEvent,
SessionEventType,
ToolExecutionCompleteError,
ToolExecutionCompleteResult,
)
from copilot.generated.session_events import Data, ErrorClass, Result, SessionEvent, SessionEventType
from copilot.tools import ToolInvocation, ToolResult
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
@@ -218,18 +212,6 @@ class TestGitHubCopilotAgentInit:
opts["model"] = "mutated"
assert agent._settings.get("model") == "gpt-5.1-mini"
def test_init_stores_instruction_directories(self) -> None:
"""Test that instruction_directories are stored on the agent instance."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
default_options={"instruction_directories": ["/my/instructions"]}
)
assert agent._instruction_directories == ["/my/instructions"] # type: ignore
def test_init_without_instruction_directories(self) -> None:
"""Test that instruction_directories default to None when not provided."""
agent = GitHubCopilotAgent()
assert agent._instruction_directories is None # type: ignore
class TestGitHubCopilotAgentLifecycle:
"""Test cases for agent lifecycle management."""
@@ -312,50 +294,6 @@ class TestGitHubCopilotAgentLifecycle:
assert call_args.cli_path == "/custom/path"
assert call_args.log_level == "debug"
async def test_start_passes_copilot_home_to_subprocess_config(self) -> None:
"""Test that copilot_home is passed through to SubprocessConfig."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
default_options={"copilot_home": "/custom/copilot/home"}
)
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home == "/custom/copilot/home"
async def test_start_copilot_home_not_set_when_unspecified(self) -> None:
"""Test that copilot_home is not included in SubprocessConfig when not specified."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client
agent = GitHubCopilotAgent()
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home is None
async def test_start_copilot_home_from_env_variable(self) -> None:
"""Test that copilot_home can be set via GITHUB_COPILOT_COPILOT_HOME env variable."""
with (
patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient,
patch.dict("os.environ", {"GITHUB_COPILOT_COPILOT_HOME": "/env/copilot/home"}),
):
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client
agent = GitHubCopilotAgent()
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home == "/env/copilot/home"
class TestGitHubCopilotAgentRun:
"""Test cases for run method."""
@@ -599,7 +537,7 @@ class TestGitHubCopilotAgentRunStreaming:
"""Test that TOOL_EXECUTION_COMPLETE events produce function_result content."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_abc123"
tool_event_data.result = ToolExecutionCompleteResult(content="Sunny, 72°F")
tool_event_data.result = Result(content="Sunny, 72°F")
tool_event_data.success = True
tool_event_data.error = None
@@ -714,9 +652,9 @@ class TestGitHubCopilotAgentRunStreaming:
"""Test that a failed tool result surfaces the error as exception."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_fail"
tool_event_data.result = ToolExecutionCompleteResult(content="Error: connection timeout")
tool_event_data.result = Result(content="Error: connection timeout")
tool_event_data.success = False
tool_event_data.error = ToolExecutionCompleteError(message="connection timeout")
tool_event_data.error = ErrorClass(message="connection timeout")
tool_event = SessionEvent(
data=tool_event_data,
@@ -753,7 +691,7 @@ class TestGitHubCopilotAgentRunStreaming:
"""Test that a failed tool result with a string error is surfaced."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_fail2"
tool_event_data.result = ToolExecutionCompleteResult(content="")
tool_event_data.result = Result(content="")
tool_event_data.success = False
tool_event_data.error = "something went wrong"
@@ -791,7 +729,7 @@ class TestGitHubCopilotAgentRunStreaming:
"""Test that a successful tool result with error field does not propagate exception."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_ok"
tool_event_data.result = ToolExecutionCompleteResult(content="partial result")
tool_event_data.result = Result(content="partial result")
tool_event_data.success = True
tool_event_data.error = "some warning"
@@ -879,7 +817,7 @@ class TestGitHubCopilotAgentRunStreaming:
# Tool result event
result_data = MagicMock()
result_data.tool_call_id = "call_001"
result_data.result = ToolExecutionCompleteResult(content="72°F and sunny")
result_data.result = Result(content="72°F and sunny")
result_data.success = True
result_data.error = None
tool_result_event = SessionEvent(
@@ -944,12 +882,9 @@ class TestGitHubCopilotAgentSessionManagement:
mock_session.session_id,
on_permission_request=unittest.mock.ANY,
streaming=unittest.mock.ANY,
model=unittest.mock.ANY,
system_message=unittest.mock.ANY,
tools=unittest.mock.ANY,
mcp_servers=unittest.mock.ANY,
provider=unittest.mock.ANY,
instruction_directories=unittest.mock.ANY,
)
async def test_session_config_includes_model(
@@ -1081,100 +1016,6 @@ class TestGitHubCopilotAgentSessionManagement:
assert "tools" in config
assert "on_permission_request" in config
async def test_instruction_directories_passed_to_create_session(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that instruction_directories are passed through to create_session."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/path/to/instructions", "/other/path"]},
)
await agent.start()
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == ["/path/to/instructions", "/other/path"]
async def test_instruction_directories_runtime_override(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that runtime instruction_directories take precedence over defaults."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/default/path"]},
)
await agent.start()
runtime_options: GitHubCopilotOptions = {"instruction_directories": ["/runtime/path"]}
await agent._get_or_create_session(AgentSession(), runtime_options=runtime_options) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == ["/runtime/path"]
async def test_instruction_directories_none_when_not_specified(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that instruction_directories is None when not specified."""
agent = GitHubCopilotAgent(client=mock_client)
await agent.start()
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] is None
async def test_instruction_directories_empty_list_clears_defaults(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that an explicit empty list at runtime clears the agent-level defaults."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/default/path"]},
)
await agent.start()
runtime_options: GitHubCopilotOptions = {"instruction_directories": []}
await agent._get_or_create_session(AgentSession(), runtime_options=runtime_options) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == []
async def test_instruction_directories_override_on_resumed_session(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that instruction_directories override works on resumed sessions."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/default/path"]},
)
await agent.start()
# Simulate a session that already has a service_session_id (resume path)
session = AgentSession()
session.service_session_id = "existing-session-id"
runtime_options: GitHubCopilotOptions = {"instruction_directories": ["/override/path"]}
await agent._get_or_create_session(session, runtime_options=runtime_options) # type: ignore
call_args = mock_client.resume_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == ["/override/path"]
class TestGitHubCopilotAgentMCPServers:
"""Test cases for MCP server configuration."""
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260501"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"hyperlight-sandbox>=0.4.0,<0.5",
"hyperlight-sandbox-backend-wasm>=0.4.0,<0.5 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
"hyperlight-sandbox-python-guest>=0.4.0,<0.5",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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 = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.2.2,<2",
]
[project.optional-dependencies]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"mem0ai>=1.0.0,<2",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
license-files = ["LICENSE"]
urls.homepage = "https://learn.microsoft.com/en-us/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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"ollama>=0.5.3,<0.5.4",
]
+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.3.0"
version = "1.2.2"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"openai>=1.99.0,<3",
]
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
]
[tool.uv]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"azure-core>=1.30.0,<2",
"httpx>=0.27.0,<0.29",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"redis>=6.4.0,<7.2.1",
"redisvl>=0.11.0,<0.16",
"numpy>=2.2.6,<3"
+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.3.0"
version = "1.2.2"
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.3.0",
"agent-framework-core[all]==1.2.2",
]
[dependency-groups]
@@ -23,7 +23,6 @@ The following environment variables can be configured:
| `GITHUB_COPILOT_MODEL` | Model to use (e.g., "gpt-5", "claude-sonnet-4") | Server default |
| `GITHUB_COPILOT_TIMEOUT` | Request timeout in seconds | `60` |
| `GITHUB_COPILOT_LOG_LEVEL` | CLI log level | `info` |
| `GITHUB_COPILOT_COPILOT_HOME` | Directory for CLI session state and config | `~/.copilot` |
## Observability
@@ -51,5 +50,4 @@ See the [observability samples](../../../02-agents/observability/) for full exam
| [`github_copilot_with_file_operations.py`](github_copilot_with_file_operations.py) | Shows how to enable file read and write permissions. Demonstrates reading file contents and creating new files. |
| [`github_copilot_with_url.py`](github_copilot_with_url.py) | Shows how to enable URL fetching permissions. Demonstrates fetching and processing web content. |
| [`github_copilot_with_mcp.py`](github_copilot_with_mcp.py) | Shows how to configure MCP (Model Context Protocol) servers, including local (stdio) and remote (HTTP) servers. |
| [`github_copilot_with_instruction_directories.py`](github_copilot_with_instruction_directories.py) | Shows how to configure custom instruction directories for project-specific or team-shared guidelines. |
| [`github_copilot_with_multiple_permissions.py`](github_copilot_with_multiple_permissions.py) | Shows how to combine multiple permission types for complex tasks that require shell, read, and write access. |
@@ -1,137 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""
GitHub Copilot Agent with Instruction Directories
This sample demonstrates how to configure custom instruction directories with
GitHubCopilotAgent. Instruction directories let the CLI load project-specific
or team-shared instruction files that shape the agent's behavior beyond the
default system message.
Use cases:
- Point the agent at a team-shared set of coding conventions.
- Load project-specific guidelines from a local `.copilot/instructions/` folder.
- Override or augment default instructions per session at runtime.
Environment variables (optional):
- GITHUB_COPILOT_CLI_PATH - Path to the Copilot CLI executable
- GITHUB_COPILOT_MODEL - Model to use (e.g., "gpt-5", "claude-sonnet-4")
"""
import asyncio
from pathlib import Path
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
async def default_instructions_example() -> None:
"""Example of pointing the agent at project-specific instruction directories."""
print("=== Instruction Directories (Default) ===\n")
# 1. Define instruction directories.
# These paths contain custom instruction files the CLI will load
# alongside its built-in instructions.
project_root = Path.cwd()
instruction_dirs = [
str(project_root / ".copilot" / "instructions"),
str(project_root / "docs" / "agent-guidelines"),
]
# 2. Create the agent with instruction directories in default_options.
# These directories apply to every session created by this agent.
agent = GitHubCopilotAgent(
instructions="You are a helpful coding assistant.",
default_options={
"on_permission_request": prompt_permission,
"instruction_directories": instruction_dirs,
},
)
# 3. Run the agent — instruction files from those directories are loaded
# automatically by the CLI when the session starts.
async with agent:
query = "Summarize the coding conventions I should follow in this project."
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result}\n")
async def runtime_override_example() -> None:
"""Example of overriding instruction directories at runtime."""
print("=== Instruction Directories (Runtime Override) ===\n")
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant.",
default_options={
"on_permission_request": prompt_permission,
"instruction_directories": ["/team/shared/instructions"],
},
)
async with agent:
# First call uses the default instruction directories
query = "What instructions are you following?"
print(f"User: {query}")
result1 = await agent.run(query)
print(f"Agent: {result1}\n")
# Second call overrides with different instruction directories at runtime.
# Runtime options take precedence over the defaults for that session.
print("Overriding with project-specific instructions...\n")
query2 = "Now what instructions are you following?"
print(f"User: {query2}")
result2 = await agent.run(
query2,
options={
"instruction_directories": ["/project/specific/instructions"],
},
)
print(f"Agent: {result2}\n")
async def main() -> None:
print("=== GitHub Copilot Agent with Instruction Directories ===\n")
await default_instructions_example()
await runtime_override_example()
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
=== GitHub Copilot Agent with Instruction Directories ===
=== Instruction Directories (Default) ===
User: Summarize the coding conventions I should follow in this project.
Agent: Based on the project instructions, you should follow these conventions...
=== Instruction Directories (Runtime Override) ===
User: What instructions are you following?
Agent: I'm following the team-shared coding guidelines which include...
Overriding with project-specific instructions...
User: Now what instructions are you following?
Agent: I'm now following the project-specific instructions which include...
"""
+10 -12
View File
@@ -10,8 +10,7 @@ Start with file-based or code-defined skills, then explore combining them and ad
|--------|-------------|
| [**file_based_skill**](file_based_skill/) | Define skills as `SKILL.md` files on disk with reference documents and executable scripts. Uses the unit-converter skill. |
| [**code_defined_skill**](code_defined_skill/) | Define skills entirely in Python code using `Skill`, `@skill.resource`, and `@skill.script` decorators. Uses a code-defined unit-converter skill. |
| [**class_based_skill**](class_based_skill/) | Define skills as Python classes using `ClassSkill` with `@ClassSkill.resource` and `@ClassSkill.script` decorators for auto-discovery. Uses a class-based unit-converter skill. |
| [**mixed_skills**](mixed_skills/) | Combine code-defined, class-based, and file-based skills in a single agent. Uses a code-defined volume-converter, a class-based temperature-converter, and a file-based unit-converter. |
| [**mixed_skills**](mixed_skills/) | Combine code-defined and file-based skills in a single agent. Uses a code-defined volume-converter and a file-based unit-converter. |
| [**script_approval**](script_approval/) | Require human-in-the-loop approval before executing skill scripts |
## Key Concepts
@@ -24,18 +23,17 @@ Skills use a three-step interaction model to minimize token usage:
2. **Load** — Full instructions are loaded on-demand via the `load_skill` tool
3. **Access** — Resources are read via `read_skill_resource`; scripts are executed via `run_skill_script`
### File-Based vs Code-Defined vs Class-Based Skills
### File-Based vs Code-Defined Skills
| Aspect | File-Based | Code-Defined | Class-Based |
|--------|-----------|--------------|-------------|
| Definition | `SKILL.md` files on disk | `Skill` instances in Python | Classes extending `ClassSkill` |
| Resources | Static files in `references/` and `assets/` directories | Callable functions via `@skill.resource` decorator | `@ClassSkill.resource` decorator (auto-discovered) |
| Scripts | Python files in `scripts/` directory (executed via subprocess) | Callable functions via `@skill.script` decorator (executed in-process) | `@ClassSkill.script` decorator (executed in-process) |
| Discovery | Automatic via `skill_paths` parameter | Explicit via `skills` parameter | Explicit via `skills` parameter |
| Dynamic content | No (static files only) | Yes (functions can generate content at runtime) | Yes (functions can generate content at runtime) |
| Sharing pattern | Copy skill directory | Inline or shared instances | Package in shared libraries/PyPI |
| Aspect | File-Based | Code-Defined |
|--------|-----------|--------------|
| Definition | `SKILL.md` files on disk | `Skill` instances in Python |
| Resources | Static files in `references/` and `assets/` directories | Callable functions via `@skill.resource` decorator |
| Scripts | Python files in `scripts/` directory (executed via subprocess) | Callable functions via `@skill.script` decorator (executed in-process) |
| Discovery | Automatic via `skill_paths` parameter | Explicit via `skills` parameter |
| Dynamic content | No (static files only) | Yes (functions can generate content at runtime) |
All three types can be combined in a single `SkillsProvider` — see the [mixed_skills](mixed_skills/) sample.
Both types can be combined in a single `SkillsProvider` — see the [mixed_skills](mixed_skills/) sample.
### Script Execution
@@ -1,71 +0,0 @@
# Class-Based Agent Skills
This sample demonstrates how to define **Agent Skills as Python classes** using `ClassSkill`.
## What's Demonstrated
- Creating skills as classes that extend `ClassSkill`
- Bundling name, description, instructions, resources, and scripts into a single class
- Using `@ClassSkill.resource` decorator for automatic resource discovery
- Using `@ClassSkill.script` decorator for automatic script discovery
- Lazy-loading and caching of resources and scripts
- Registering class-based skills with `SkillsProvider`
## Skills Included
### unit-converter (class-based)
A `UnitConverterSkill` class that converts between common units. Defined in `class_based_skill.py`:
- `conversion-table` — Static resource with factor table
- `convert` — Script that performs `value × factor` conversion
## Project Structure
```
class_based_skill/
├── class_based_skill.py
└── README.md
```
## Running the Sample
### Prerequisites
- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`)
### Environment Variables
Set the required environment variables in a `.env` file (see `python/.env.example`):
- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
- `FOUNDRY_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`)
### Authentication
This sample uses `AzureCliCredential` for authentication. Run `az login` in your terminal before running the sample.
### Run
```bash
cd python
uv run samples/02-agents/skills/class_based_skill/class_based_skill.py
```
### Expected Output
```
Converting units with class-based skills
------------------------------------------------------------
Agent: Here are your conversions:
1. **26.2 miles → 42.16 km** (a marathon distance)
2. **75 kg → 165.35 lbs**
```
## Learn More
- [Agent Skills Specification](https://agentskills.io/)
- [Code-Defined Skills Sample](../code_defined_skill/)
- [Mixed Skills Sample](../mixed_skills/)
- [Microsoft Agent Framework Documentation](../../../../../docs/)
@@ -1,145 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import os
# Uncomment this filter to suppress the experimental Skills warning before
# using the sample's Skills APIs.
# import warnings # isort: skip
# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning)
from textwrap import dedent
from agent_framework import Agent, ClassSkill, SkillsProvider
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
"""
Class-Based Agent Skills Define skills as Python classes
This sample demonstrates how to define Agent Skills as reusable Python classes
by subclassing ``ClassSkill``. Class-based skills bundle all components (name,
description, instructions, resources, scripts) into a single class, making
them easy to package and distribute via shared libraries or PyPI.
Key concepts shown:
- Subclassing ``ClassSkill`` to create a self-contained skill
- Using ``@property`` + ``@ClassSkill.resource`` (bare) name defaults to method name
- Using ``@ClassSkill.script(name=..., description=...)`` explicit name and description
- Lazy-loading and caching of resources and scripts
"""
# Load environment variables from .env file
load_dotenv()
# ---------------------------------------------------------------------------
# Class-Based Skill: UnitConverterSkill
# ---------------------------------------------------------------------------
class UnitConverterSkill(ClassSkill):
"""A unit-converter skill defined as a Python class.
Converts between common units (mileskm, poundskg) using a
conversion factor. Resources and scripts are discovered automatically
via decorators.
"""
def __init__(self) -> None:
super().__init__(
name="unit-converter",
description=(
"Convert between common units using a multiplication factor. "
"Use when asked to convert miles, kilometers, pounds, or kilograms."
),
)
@property
def instructions(self) -> str:
return dedent("""\
Use this skill when the user asks to convert between units.
1. Review the conversion-table resource to find the factor for the requested conversion.
2. Use the convert script, passing the value and factor from the table.
3. Present the result clearly with both units.
""")
# 1. Property with bare decorator — name defaults to the method name
# ("conversion_table" → "conversion-table"), no description.
# Place @property first, then @ClassSkill.resource.
@property
@ClassSkill.resource
def conversion_table(self) -> str:
"""Lookup table of multiplication factors for common unit conversions."""
return dedent("""\
# Conversion Tables
Formula: **result = value × factor**
| From | To | Factor |
|-------------|-------------|----------|
| miles | kilometers | 1.60934 |
| kilometers | miles | 0.621371 |
| pounds | kilograms | 0.453592 |
| kilograms | pounds | 2.20462 |
""")
# 2. Explicit name — overrides the method name
# 3. Explicit description — provides a description for the script
@ClassSkill.script(name="convert", description="Multiplies a value by a conversion factor.")
def convert_units(self, value: float, factor: float) -> str:
"""Convert a value using a multiplication factor: result = value × factor.
Args:
value: The numeric value to convert.
factor: Conversion factor from the conversion table.
Returns:
JSON string with the inputs and converted result.
"""
result = round(value * factor, 4)
return json.dumps({"value": value, "factor": factor, "result": result})
async def main() -> None:
"""Run the class-based skills demo."""
endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
client = FoundryChatClient(
project_endpoint=endpoint,
model=deployment,
credential=AzureCliCredential(),
)
# Instantiate the class-based skill and pass it to the provider
unit_converter = UnitConverterSkill()
async with Agent(
client=client,
instructions="You are a helpful assistant that can convert units.",
context_providers=[SkillsProvider(unit_converter)],
) as agent:
print("Converting units with class-based skills")
print("-" * 60)
response = await agent.run(
"How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"
)
print(f"Agent: {response}\n")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
Converting units with class-based skills
------------------------------------------------------------
Agent: Here are your conversions:
1. **26.2 miles 42.16 km** (a marathon distance)
2. **75 kg 165.35 lbs**
"""
@@ -1,18 +1,17 @@
# Mixed Skills — Code, Class, and File Skills
# Mixed Skills — Code Skills and File Skills
This sample demonstrates how to combine **code-defined skills**,
**class-based skills**, and **file-based skills** in a single agent using
`SkillsProvider`.
This sample demonstrates how to combine **code-defined skills** and
**file-based skills** in a single agent using a `SkillScriptRunner` callable
and `SkillsProvider`.
## Concepts
| Concept | Description |
|---------|-------------|
| **Code skill** | A `Skill` created in Python with `@skill.script` decorators for in-process callable functions and `@skill.resource` for dynamic content |
| **Class skill** | A self-contained skill class extending `ClassSkill`, bundling instructions, resources, and scripts |
| **File skill** | A skill discovered from a `SKILL.md` file on disk, with reference documents and executable script files |
| **`script_runner`** | A callable (sync or async) satisfying the `SkillScriptRunner` protocol — required when file skills have scripts |
| **`SkillsProvider`** | Registers code-defined, class-based, and file-based skills in a single provider |
| **`SkillsProvider`** | Registers both code-defined and file-based skills in a single provider |
## Skills in This Sample
@@ -25,15 +24,6 @@ Defined entirely in Python code using decorators:
Code scripts run **in-process** — no subprocess or external runner needed.
### temperature-converter (class skill)
Defined as a `TemperatureConverterSkill` class extending `ClassSkill`:
- **`@ClassSkill.resource`** — `temperature-conversion-formulas`: °F↔°C↔K formulas
- **`@ClassSkill.script`** — `convert-temperature`: converts between temperature scales
Class-based scripts run **in-process** — no subprocess or external runner needed.
### unit-converter (file skill)
Discovered from `skills/unit-converter/SKILL.md`:
@@ -53,10 +43,7 @@ File scripts are executed as **local Python subprocesses** via the
│ AggregatingSkillsSource([ │
│ FileSkillsSource("./skills", # file skills │
│ script_runner=runner), │
│ InMemorySkillsSource([
│ volume_skill, # code skill │
│ temp_converter, # class skill │
│ ]), │
│ InMemorySkillsSource([skill]), # code skills
│ ]) │
│ ) │
│ ) │
@@ -67,7 +54,6 @@ File scripts are executed as **local Python subprocesses** via the
│ script_runner(skill, script, args) │
│ │
│ • Code scripts (@skill.script) → in-process call │
│ • Class scripts (@ClassSkill.script) → in-process call │
│ • File scripts (scripts/*.py) → subprocess via │
│ the callback function │
└─────────────────────────────────────────────────────────────┘
@@ -16,7 +16,6 @@ from typing import Any
from agent_framework import (
Agent,
AggregatingSkillsSource,
ClassSkill,
DeduplicatingSkillsSource,
FileSkillsSource,
InlineSkill,
@@ -35,32 +34,28 @@ if _SKILLS_ROOT not in sys.path:
from subprocess_script_runner import subprocess_script_runner # noqa: E402
"""
Mixed Skills Code, class, and file skills in a single agent
Mixed Skills Code skills and file skills in a single agent
This sample demonstrates how to combine **code-defined skills** (with
``@skill.script`` and ``@skill.resource`` decorators), **class-based skills**
(subclassing ``ClassSkill``), and **file-based skills** (discovered from
``SKILL.md`` files on disk) in a single agent using ``SkillsProvider`` and
a ``SkillScriptRunner`` callable.
``@skill.script`` and ``@skill.resource`` decorators) and **file-based skills**
(discovered from ``SKILL.md`` files on disk) in a single agent using
``SkillsProvider`` and a ``SkillScriptRunner`` callable.
Key concepts shown:
- Code skills with ``@skill.script``: executable Python functions the agent
can invoke directly in-process.
- Code skills with ``@skill.resource``: dynamic content the agent can read
on demand.
- Class skills: self-contained skill classes extending ``ClassSkill``.
- File skills from disk: ``SKILL.md`` files with reference documents and
executable script files.
- ``script_runner``: routes **file-based** script execution
through a callback, enabling custom handling (e.g. subprocess calls).
Code-defined and class-based scripts run in-process automatically.
Code-defined scripts (``@skill.script``) run in-process automatically.
The sample registers three skills:
The sample registers two skills:
1. **volume-converter** (code skill) converts between gallons and liters using
``@skill.script`` for conversion and ``@skill.resource`` for the factor table.
2. **temperature-converter** (class skill) converts between temperature scales
(°F°CK) using a ``ClassSkill`` subclass.
3. **unit-converter** (file skill) converts between common units (mileskm,
2. **unit-converter** (file skill) converts between common units (mileskm,
poundskg) via a subprocess-executed Python script discovered from
``skills/unit-converter/SKILL.md``.
"""
@@ -115,68 +110,9 @@ def convert_volume(value: float, factor: float) -> str:
# ---------------------------------------------------------------------------
# 2. Define a class-based skill for temperature conversion
# 2. Wire everything together and run the agent
# ---------------------------------------------------------------------------
class TemperatureConverterSkill(ClassSkill):
"""A temperature-converter skill defined as a Python class.
Converts between temperature scales (Fahrenheit, Celsius, Kelvin).
Resources and scripts are discovered automatically via decorators.
"""
def __init__(self) -> None:
super().__init__(
name="temperature-converter",
description="Convert between temperature scales (Fahrenheit, Celsius, Kelvin).",
)
@property
def instructions(self) -> str:
return dedent("""\
Use this skill when the user asks to convert temperatures.
1. Read the temperature-conversion-formulas resource to find the factor and offset
for the requested conversion.
2. Use the convert-temperature script, passing value, factor, and offset.
3. Present the result clearly with both temperature scales.
""")
@ClassSkill.resource(name="temperature-conversion-formulas")
def formulas(self) -> str:
"""Temperature conversion formulas reference table."""
return dedent("""\
# Temperature Conversion Formulas
Formula: **result = value × factor + offset**
| From | To | Factor | Offset |
|-------------|-------------|----------|-----------|
| Fahrenheit | Celsius | 0.555556 | -17.7778 |
| Celsius | Fahrenheit | 1.8 | 32 |
| Celsius | Kelvin | 1 | 273.15 |
| Kelvin | Celsius | 1 | -273.15 |
""")
@ClassSkill.script(name="convert-temperature")
def convert_temperature(self, value: float, factor: float, offset: float = 0) -> str:
"""Convert a temperature value using factor and offset from the formulas resource.
Args:
value: The numeric temperature value to convert.
factor: Conversion factor from the formulas resource.
offset: Offset to add after multiplying (default 0).
Returns:
JSON string with the conversion result.
"""
result = round(value * factor + offset, 4)
return json.dumps({"value": value, "factor": factor, "offset": offset, "result": result})
# ---------------------------------------------------------------------------
# 3. Wire everything together and run the agent
# ---------------------------------------------------------------------------
async def main() -> None:
"""Run the combined skills demo."""
@@ -190,11 +126,9 @@ async def main() -> None:
credential=AzureCliCredential(),
)
# Create the SkillsProvider with code, class, and file skills.
# The script_runner handles file-based scripts; code-defined and
# class-based scripts run in-process automatically.
temperature_converter = TemperatureConverterSkill()
# Create the SkillsProvider with both code and file skills.
# The script_runner handles file-based scripts; code-defined scripts
# (@skill.script) run in-process automatically.
skills_provider = SkillsProvider(
DeduplicatingSkillsSource(
AggregatingSkillsSource([
@@ -202,7 +136,7 @@ async def main() -> None:
str(Path(__file__).parent / "skills"),
script_runner=subprocess_script_runner,
),
InMemorySkillsSource([volume_converter_skill, temperature_converter]),
InMemorySkillsSource([volume_converter_skill]),
])
)
)
@@ -210,17 +144,14 @@ async def main() -> None:
# Run the agent
async with Agent(
client=client,
instructions="You are a helpful assistant that can convert units, volumes, and temperatures.",
instructions="You are a helpful assistant that can convert units.",
context_providers=[skills_provider],
) as agent:
# Ask the agent to use all three skills
print("Converting with mixed skills (file + code + class)")
# Ask the agent to use both skills
print("Converting units")
print("-" * 60)
response = await agent.run(
"I need three conversions: "
"1) How many kilometers is a marathon (26.2 miles)? "
"2) How many liters is a 5-gallon bucket? "
"3) What is 98.6°F in Celsius?"
"How many kilometers is a marathon (26.2 miles)? And how many liters is a 5-gallon bucket?"
)
print(f"Agent: {response}\n")
@@ -231,11 +162,12 @@ if __name__ == "__main__":
"""
Sample output:
Converting with mixed skills (file + code + class)
Converting units
------------------------------------------------------------
Agent: Here are your conversions:
1. **26.2 miles 42.16 km** (a marathon distance)
2. **5 gallons 18.93 liters**
3. **98.6°F 37.0°C**
I used the conversion factors from each skill's reference table.
"""
@@ -2,18 +2,16 @@
"""Aggregate per-provider JUnit XML test results and generate a trend report.
Parses JUnit XML files produced by CI jobs both ``pytest.xml`` (Python) and
xunit v3 ``*.junit`` (dotnet) merges them into a single run, combines
with historical data, and generates a markdown trend table.
Parses ``pytest.xml`` (JUnit XML) files produced by each CI job, merges them
into a single run, combines with historical data, and generates a markdown
trend table the same pattern used by ``scripts/sample_validation/aggregate.py``.
Usage (from CI):
python aggregate.py <reports-dir> <history-file> <output-file>
The reports directory is expected to contain artifact subdirectories. Two
layouts are supported:
- **Python (pytest):** ``test-results-<provider>/pytest.xml``
- **Dotnet (xunit):** ``dotnet-test-results-<tfm>-<os>/*.junit``
The reports directory is expected to contain subdirectories named
``test-results-<provider>/`` each containing a ``pytest.xml`` file
(created by ``actions/download-artifact``).
"""
from __future__ import annotations
@@ -48,21 +46,9 @@ def _format_run_label(timestamp: str) -> str:
def _derive_provider(directory_name: str) -> str:
"""Derive a provider label from a report directory name.
Handles both Python and dotnet naming conventions:
- ``test-results-openai`` ``OpenAI``
- ``test-results-azure-openai`` ``Azure OpenAI``
- ``dotnet-test-results-net10.0-ubuntu-latest`` ``net10.0 (ubuntu)``
``test-results-openai`` ``OpenAI``
``test-results-azure-openai`` ``Azure OpenAI``
"""
# Dotnet convention: dotnet-test-results-<framework>-<os>
if directory_name.startswith("dotnet-test-results-"):
raw = directory_name.replace("dotnet-test-results-", "")
# e.g. "net10.0-ubuntu-latest" → framework="net10.0", os="ubuntu-latest"
parts = raw.split("-", 1)
framework = parts[0]
os_label = parts[1].split("-")[0] if len(parts) > 1 else ""
return f"{framework} ({os_label})" if os_label else framework
# Python convention: test-results-<provider>
raw = directory_name.replace("test-results-", "")
known = {
"openai": "OpenAI",
@@ -116,21 +102,11 @@ def _parse_junit_xml(xml_path: Path) -> list[dict[str, str]]:
# it appends the class name, e.g.:
# "packages.foundry.tests.foundry.test_foundry_embedding_client.TestFoundryEmbeddingIntegration"
# We want the file-level module: "test_foundry_embedding_client"
#
# xunit (dotnet) writes classname as the full C# type, e.g.:
# "OpenAIChatCompletion.IntegrationTests.ChatCompletionTests"
# We want the project prefix: "OpenAIChatCompletion"
if classname:
parts = classname.rsplit(".", 2)
# If the last segment starts with uppercase it's a class name — take the one before it
if len(parts) >= 2 and parts[-1][0:1].isupper():
# For dotnet: if the penultimate part is "IntegrationTests" or "UnitTests",
# use the part before that (the project name) instead
if parts[-2] in ("IntegrationTests", "UnitTests") and len(parts) >= 3:
# parts[0] may contain dots — take the last segment of it
module = parts[0].rsplit(".", 1)[-1]
else:
module = parts[-2]
module = parts[-2]
else:
module = parts[-1]
else:
@@ -172,61 +148,28 @@ def _parse_junit_xml(xml_path: Path) -> list[dict[str, str]]:
# ---------------------------------------------------------------------------
def _discover_xml_files(reports_dir: Path) -> list[tuple[str, Path]]:
"""Discover JUnit XML test result files in artifact subdirectories.
Handles two directory layouts:
- **Python (pytest):** ``test-results-<provider>/pytest.xml``
- **Dotnet (xunit):** ``dotnet-test-results-<tfm>-<os>/*.junit``
Returns:
List of ``(directory_name, xml_path)`` tuples.
"""
xml_files: list[tuple[str, Path]] = []
if not reports_dir.is_dir():
return xml_files
for subdir in sorted(reports_dir.iterdir()):
if not subdir.is_dir():
continue
# Python layout: single pytest.xml per artifact
pytest_xml = subdir / "pytest.xml"
if pytest_xml.exists():
xml_files.append((subdir.name, pytest_xml))
continue
# Dotnet layout: multiple *.junit files per artifact
junit_files = sorted(subdir.rglob("*.junit"))
for jf in junit_files:
xml_files.append((subdir.name, jf))
# Fallback: any .xml file that looks like JUnit (not .trx, not cobertura)
if not junit_files:
for xf in sorted(subdir.rglob("*.xml")):
if xf.suffix == ".xml" and not xf.name.endswith(".cobertura.xml"):
xml_files.append((subdir.name, xf))
return xml_files
def load_current_run(reports_dir: Path) -> dict[str, Any]:
"""Load per-provider JUnit XML reports from the current CI run and merge.
Supports both pytest (Python) and xunit v3 (dotnet) JUnit XML formats.
Args:
reports_dir: Directory containing artifact subdirectories with XML reports.
reports_dir: Directory containing ``test-results-<provider>/`` subdirs.
Returns:
Merged run dict with ``timestamp``, ``summary``, ``results``.
"""
combined_results: dict[str, dict[str, str]] = {} # nodeid → {status, provider}
xml_files = _discover_xml_files(reports_dir)
# actions/download-artifact creates: reports_dir/test-results-openai/pytest.xml
xml_files: list[tuple[str, Path]] = []
if reports_dir.is_dir():
for subdir in sorted(reports_dir.iterdir()):
if subdir.is_dir():
xml_file = subdir / "pytest.xml"
if xml_file.exists():
xml_files.append((subdir.name, xml_file))
if not xml_files:
print(f"Warning: No JUnit XML files found in {reports_dir}")
print(f"Warning: No pytest.xml files found in {reports_dir}")
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"summary": {
@@ -238,42 +181,19 @@ def load_current_run(reports_dir: Path) -> dict[str, Any]:
"results": {},
}
# Dotnet tests always run under multiple frameworks, so we always
# qualify their keys with the provider to ensure deterministic,
# stable keys across runs regardless of file parse order.
is_dotnet = any(d.startswith("dotnet-test-results-") for d, _ in xml_files)
for dir_name, xml_file in xml_files:
print(f" Loading: {xml_file}")
provider = _derive_provider(dir_name)
tests = _parse_junit_xml(xml_file)
for test in tests:
raw_id = test["nodeid"]
key = f"{provider}::{raw_id}" if is_dotnet else raw_id
combined_results[key] = {
combined_results[test["nodeid"]] = {
"status": test["status"],
"provider": provider,
"module": test.get("module", ""),
}
# Build per-provider summary counts so the report can show one row per
# framework (dotnet) or per provider (Python).
provider_counts: dict[str, dict[str, int]] = {}
for r in combined_results.values():
prov = r.get("provider", "Unknown")
if prov not in provider_counts:
provider_counts[prov] = {"total": 0, "passed": 0, "failed": 0, "skipped": 0}
provider_counts[prov]["total"] += 1
st = r["status"]
if st == "passed":
provider_counts[prov]["passed"] += 1
elif st in ("failed", "error"):
provider_counts[prov]["failed"] += 1
elif st == "skipped":
provider_counts[prov]["skipped"] += 1
# Overall summary (sum across all providers).
# Build summary counts using mutually exclusive status buckets.
# Errors are folded into the failed count for display purposes.
statuses = [r["status"] for r in combined_results.values()]
summary = {
"total": len(statuses),
@@ -285,7 +205,6 @@ def load_current_run(reports_dir: Path) -> dict[str, Any]:
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"summary": summary,
"provider_summaries": provider_counts,
"results": combined_results,
}
@@ -334,29 +253,7 @@ def generate_trend_report(runs: list[dict[str, Any]]) -> str:
"",
]
# Detect whether this is a dotnet report (provider-qualified keys).
is_dotnet = False
for run in runs:
provider_sums = run.get("provider_summaries", {})
if any(p.startswith("net") for p in provider_sums):
is_dotnet = True
break
if is_dotnet:
_generate_dotnet_report(lines, runs)
else:
_generate_python_report(lines, runs)
lines.append("")
lines.append("**Legend:** ✅ Passed · ❌ Failed · ⏭️ Skipped · ⚠️ Expected Failure (xfail) · N/A Not available")
lines.append("")
return "\n".join(lines)
def _generate_python_report(lines: list[str], runs: list[dict[str, Any]]) -> None:
"""Generate the original single-table Python report format."""
# --- Overall status table ---
# --- Overall status table (most recent first) ---
lines.append("## Overall Status (Last 5 Runs)")
lines.append("")
lines.append("| Run | Total | ✅ Passed | ❌ Failed | ⏭️ Skipped |")
@@ -379,91 +276,27 @@ def _generate_python_report(lines: list[str], runs: list[dict[str, Any]]) -> Non
lines.append("")
# --- Single per-test results table ---
_generate_per_test_table(lines, runs, "## Per-Test Results")
# --- Per-test results table ---
lines.append("## Per-Test Results")
lines.append("")
def _generate_dotnet_report(lines: list[str], runs: list[dict[str, Any]]) -> None:
"""Generate per-framework tables for dotnet (net10.0, net472, etc.)."""
# Collect all providers seen across all runs, sorted for stable ordering
all_providers: set[str] = set()
for run in runs:
all_providers.update(run.get("provider_summaries", {}).keys())
providers = sorted(all_providers)
for provider in providers:
lines.append(f"## {provider}")
lines.append("")
# --- Per-provider summary table ---
lines.append("| Run | Total | ✅ Passed | ❌ Failed | ⏭️ Skipped |")
lines.append("|-----|-------|-----------|-----------|------------|")
for run in reversed(runs):
ps = run.get("provider_summaries", {}).get(provider, {})
total = ps.get("total", 0)
label = _format_run_label(run["timestamp"])
if total == 0:
lines.append(f"| {label} | N/A | N/A | N/A | N/A |")
else:
lines.append(
f"| {label} "
f"| {total} "
f"| {ps.get('passed', 0)}/{total} "
f"| {ps.get('failed', 0)}/{total} "
f"| {ps.get('skipped', 0)}/{total} |"
)
for _ in range(MAX_HISTORY - len(runs)):
lines.append("| N/A | N/A | N/A | N/A | N/A |")
lines.append("")
# --- Per-test table filtered to this provider ---
_generate_per_test_table(
lines, runs,
heading=None,
provider_filter=provider,
)
def _generate_per_test_table(
lines: list[str],
runs: list[dict[str, Any]],
heading: str | None = None,
provider_filter: str | None = None,
) -> None:
"""Emit a per-test trend table, optionally filtered to a single provider."""
if heading:
lines.append(heading)
lines.append("")
# Collect all test nodeids (and metadata) across all runs
all_tests: dict[str, str] = {} # nodeid → provider
all_modules: dict[str, str] = {} # nodeid → module
# Collect all test nodeids, providers, and modules across all runs
all_tests: dict[str, str] = {} # nodeid → provider (from most recent run)
all_modules: dict[str, str] = {} # nodeid → module (from most recent run)
for run in runs:
for nodeid, info in run.get("results", {}).items():
if not isinstance(info, dict):
continue
prov = info.get("provider", "Unknown")
if provider_filter and prov != provider_filter:
continue
module = info.get("module", "")
all_tests[nodeid] = prov
provider = info.get("provider", "Unknown") if isinstance(info, dict) else "Unknown"
module = info.get("module", "") if isinstance(info, dict) else ""
all_tests[nodeid] = provider
all_modules[nodeid] = module
if not all_tests:
lines.append("*No test results available.*")
lines.append("")
return
return "\n".join(lines)
# Build header
if provider_filter:
header = "| Test | File |"
separator = "|------|------|"
else:
header = "| Test | File | Provider |"
separator = "|------|------|----------|"
# Build header (most recent run first)
header = "| Test | File | Provider |"
separator = "|------|------|----------|"
for run in reversed(runs):
label = _format_run_label(run["timestamp"])
header += f" {label} |"
@@ -475,15 +308,12 @@ def _generate_per_test_table(
lines.append(header)
lines.append(separator)
# Sort by module then test name
for nodeid in sorted(all_tests, key=lambda n: (all_modules.get(n, ""), n)):
# Sort by provider then test name
for nodeid in sorted(all_tests, key=lambda n: (all_tests[n], n)):
provider = all_tests[nodeid]
module = all_modules.get(nodeid, "")
short = _short_name(nodeid)
if provider_filter:
row = f"| `{short}` | `{module}` |"
else:
provider = all_tests[nodeid]
row = f"| `{short}` | `{module}` | {provider} |"
row = f"| `{short}` | `{module}` | {provider} |"
for run in reversed(runs):
result = run.get("results", {}).get(nodeid)
@@ -500,6 +330,10 @@ def _generate_per_test_table(
lines.append(row)
lines.append("")
lines.append("**Legend:** ✅ Passed · ❌ Failed · ⏭️ Skipped · ⚠️ Expected Failure (xfail) · N/A Not available")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
+37 -37
View File
@@ -104,7 +104,7 @@ wheels = [
[[package]]
name = "agent-framework"
version = "1.3.0"
version = "1.2.2"
source = { virtual = "." }
dependencies = [
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -159,7 +159,7 @@ dev = [
[[package]]
name = "agent-framework-a2a"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/a2a" }
dependencies = [
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -174,7 +174,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ag-ui"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/ag-ui" }
dependencies = [
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -202,7 +202,7 @@ provides-extras = ["dev"]
[[package]]
name = "agent-framework-anthropic"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/anthropic" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -217,7 +217,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-ai-search"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/azure-ai-search" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -232,7 +232,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-contentunderstanding"
version = "1.0.0a260507"
version = "1.0.0a260429"
source = { editable = "packages/azure-contentunderstanding" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -253,7 +253,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-cosmos"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/azure-cosmos" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -268,7 +268,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azurefunctions"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/azurefunctions" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -290,7 +290,7 @@ dev = []
[[package]]
name = "agent-framework-bedrock"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/bedrock" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -307,7 +307,7 @@ requires-dist = [
[[package]]
name = "agent-framework-chatkit"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/chatkit" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -322,7 +322,7 @@ requires-dist = [
[[package]]
name = "agent-framework-claude"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/claude" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -337,7 +337,7 @@ requires-dist = [
[[package]]
name = "agent-framework-copilotstudio"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/copilotstudio" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -352,7 +352,7 @@ requires-dist = [
[[package]]
name = "agent-framework-core"
version = "1.3.0"
version = "1.2.2"
source = { editable = "packages/core" }
dependencies = [
{ name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -426,7 +426,7 @@ provides-extras = ["all"]
[[package]]
name = "agent-framework-declarative"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/declarative" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -453,7 +453,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }]
[[package]]
name = "agent-framework-devui"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/devui" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -491,7 +491,7 @@ provides-extras = ["dev", "all"]
[[package]]
name = "agent-framework-durabletask"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/durabletask" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -518,7 +518,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }]
[[package]]
name = "agent-framework-foundry"
version = "1.3.0"
version = "1.2.2"
source = { editable = "packages/foundry" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -537,7 +537,7 @@ requires-dist = [
[[package]]
name = "agent-framework-foundry-hosting"
version = "1.0.0a260507"
version = "1.0.0a260429"
source = { editable = "packages/foundry_hosting" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -556,7 +556,7 @@ requires-dist = [
[[package]]
name = "agent-framework-foundry-local"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/foundry_local" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -573,7 +573,7 @@ requires-dist = [
[[package]]
name = "agent-framework-gemini"
version = "1.0.0a260507"
version = "1.0.0a260429"
source = { editable = "packages/gemini" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -588,7 +588,7 @@ requires-dist = [
[[package]]
name = "agent-framework-github-copilot"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/github_copilot" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -598,12 +598,12 @@ 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 = ">=0.2.1,<=0.2.1" },
]
[[package]]
name = "agent-framework-hyperlight"
version = "1.0.0b260507"
version = "1.0.0b260501"
source = { editable = "packages/hyperlight" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -622,7 +622,7 @@ requires-dist = [
[[package]]
name = "agent-framework-lab"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/lab" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -703,7 +703,7 @@ dev = [
[[package]]
name = "agent-framework-mem0"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/mem0" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -718,7 +718,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ollama"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/ollama" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -733,7 +733,7 @@ requires-dist = [
[[package]]
name = "agent-framework-openai"
version = "1.3.0"
version = "1.2.2"
source = { editable = "packages/openai" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -748,7 +748,7 @@ requires-dist = [
[[package]]
name = "agent-framework-orchestrations"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/orchestrations" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -759,7 +759,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }]
[[package]]
name = "agent-framework-purview"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/purview" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -776,7 +776,7 @@ requires-dist = [
[[package]]
name = "agent-framework-redis"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/redis" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -2514,19 +2514,19 @@ wheels = [
[[package]]
name = "github-copilot-sdk"
version = "1.0.0b2"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
{ name = "python-dateutil", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/fe/2cb98d4b9f57f8062ea72775bde72aed1958305016753f7296398e0ceb45/github_copilot_sdk-1.0.0b2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:1b5941d8b6e3d94d42a5bec6607a26f562e6535d5c981089d23d3d224b94601c", size = 67061619, upload-time = "2026-05-06T20:02:08.636Z" },
{ url = "https://files.pythonhosted.org/packages/57/45/76567821b2d36f81e6bca78c98d265e2762733f765fa51d69602b7f81867/github_copilot_sdk-1.0.0b2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b8f6a087a0cf02bb0d33976e8f8c009578d84d701a0b28d52051304791ac70", size = 63790955, upload-time = "2026-05-06T20:02:12.354Z" },
{ url = "https://files.pythonhosted.org/packages/15/67/684b0da0b1207a2bdf025c22ee075d34a1736d61a4973651035d4fd4d8dc/github_copilot_sdk-1.0.0b2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f403638c11b82bddb81c94675fc4e8014a1bb2e86a679a39fa167dcc3ad5416a", size = 69538664, upload-time = "2026-05-06T20:02:16.363Z" },
{ url = "https://files.pythonhosted.org/packages/57/1d/80d88ecf83683535d1a16d4817f1683db3b125f52a924ebdfe9764f5e4c3/github_copilot_sdk-1.0.0b2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:433d16bb31171fee8d3a5b70259c527f63b297e83a8f8761ae1f16f14d641f32", size = 68163648, upload-time = "2026-05-06T20:02:21.139Z" },
{ url = "https://files.pythonhosted.org/packages/32/d3/b72aa2fbb3194b50b53e8cb1484f5606a1f8eedcdb0bfb5747da52079553/github_copilot_sdk-1.0.0b2-py3-none-win_amd64.whl", hash = "sha256:a6e9782dae4c3c2ab3527b45bb5de0f61998104c10e9ff64698280eaf37ab5dd", size = 62649144, upload-time = "2026-05-06T20:02:24.953Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e2/be95b8ea0ac11d1ca474e28a59284f4e395c2710734eadfb657f5de8ace2/github_copilot_sdk-1.0.0b2-py3-none-win_arm64.whl", hash = "sha256:2e97d0ce4bad67dc5929091cb429e7bbae7d4643e4908a6af256a41439000740", size = 60374365, upload-time = "2026-05-06T20:02:29.02Z" },
{ url = "https://files.pythonhosted.org/packages/67/41/76a9d50d7600bf8d26c659dc113be62e4e56e00a5cbfd544e1b5b200f45c/github_copilot_sdk-0.2.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c0823150f3b73431f04caee43d1dbafac22ae7e8bd1fc83727ee8363089ee038", size = 61076141, upload-time = "2026-04-03T20:18:22.062Z" },
{ url = "https://files.pythonhosted.org/packages/04/04/d2e8bf4587c4da270ccb9cbd5ab8a2c4b41217c2bf04a43904be8a27ae20/github_copilot_sdk-0.2.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ef7ff68eb8960515e1a2e199ac0ffb9a17cd3325266461e6edd7290e43dcf012", size = 57838464, upload-time = "2026-04-03T20:18:26.042Z" },
{ url = "https://files.pythonhosted.org/packages/78/8b/cc8ee46724bd9fdfd6afe855a043c8403ed6884c5f3a55a9737780810396/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:890f7124e3b147532a1ac6c8d5f66421ea37757b2b9990d7967f3f147a2f533a", size = 63940155, upload-time = "2026-04-03T20:18:30.297Z" },
{ url = "https://files.pythonhosted.org/packages/cf/ee/facf04e22e42d4bdd4fe3d356f3a51180a6ea769ae2ac306d0897f9bf9d9/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6502be0b9ececacbda671835e5f61c7aaa906c6b8657ee252cad6cc8335cac8e", size = 62130538, upload-time = "2026-04-03T20:18:34.061Z" },
{ url = "https://files.pythonhosted.org/packages/3f/1c/8b105f14bf61d1d304a00ac29460cb0d4e7406ceb89907d5a7b41a72fe85/github_copilot_sdk-0.2.1-py3-none-win_amd64.whl", hash = "sha256:8275ca8e387e6b29bc5155a3c02a0eb3d035c6bc7b1896253eb0d469f2385790", size = 56547331, upload-time = "2026-04-03T20:18:37.859Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c1/0ce319d2f618e9bc89f275e60b1920f4587eb0218bba6cbb84283dc7a7f3/github_copilot_sdk-0.2.1-py3-none-win_arm64.whl", hash = "sha256:1f9b59b7c41f31be416bf20818f58e25b6adc76f6d17357653fde6fbab662606", size = 54499549, upload-time = "2026-04-03T20:18:41.77Z" },
]
[[package]]