mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1aa878c94 | ||
|
|
c367916e8e | ||
|
|
1489d6620e | ||
|
|
2a9b68d1bd | ||
|
|
8bb4692678 | ||
|
|
7db163997f |
@@ -379,6 +379,14 @@ 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
|
||||
@@ -388,7 +396,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 | Tee-Object -FilePath $env:GITHUB_ENV -Append
|
||||
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry -UsePrebuiltProjectReferences | Tee-Object -FilePath $env:GITHUB_ENV -Append
|
||||
|
||||
- name: Run Foundry Hosted Agents Integration Tests
|
||||
shell: pwsh
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.29" />
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
<!-- M365 Agents SDK -->
|
||||
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
||||
|
||||
@@ -12,7 +12,9 @@ static Task<PermissionRequestResult> PromptPermission(PermissionRequest request,
|
||||
Console.Write("Approve? (y/n): ");
|
||||
|
||||
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||
string kind = input is "Y" or "YES" ? "approved" : "denied-interactively-by-user";
|
||||
PermissionRequestResultKind kind = input is "Y" or "YES"
|
||||
? PermissionRequestResultKind.Approved
|
||||
: PermissionRequestResultKind.Rejected;
|
||||
|
||||
return Task.FromResult(new PermissionRequestResult { Kind = kind });
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string prompt = string.Join("\n", messages.Select(m => m.Text));
|
||||
|
||||
// Handle DataContent as attachments
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
(List<UserMessageAttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
messages,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -443,11 +443,11 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
|
||||
}
|
||||
|
||||
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
private static async Task<(List<UserMessageAttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<UserMessageDataAttachmentsItem>? attachments = null;
|
||||
List<UserMessageAttachmentFile>? attachments = null;
|
||||
string? tempDir = null;
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
@@ -461,7 +461,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new UserMessageDataAttachmentsItemFile
|
||||
attachments.Add(new UserMessageAttachmentFile
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath)
|
||||
|
||||
@@ -41,7 +41,14 @@ param(
|
||||
|
||||
[string] $Repository = "foundry-hosting-it",
|
||||
|
||||
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer"
|
||||
[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
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -100,7 +107,60 @@ if (Test-Path $out) {
|
||||
Remove-Item -Recurse -Force $out
|
||||
}
|
||||
|
||||
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out --tl:off | Out-Host
|
||||
# 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
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dotnet publish failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
+43
-11
@@ -37,7 +37,7 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
public async Task CreateAsync_DefaultAgent_EmitsInvokeAgentSpanAsync()
|
||||
{
|
||||
// Arrange
|
||||
var activities = new List<Activity>();
|
||||
var activities = new ConcurrentActivityList();
|
||||
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.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
|
||||
var mySpan = Assert.Single(activities.Snapshot().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 List<Activity>();
|
||||
var activities = new ConcurrentActivityList();
|
||||
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.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
|
||||
var mySpan = Assert.Single(activities.Snapshot().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 List<Activity>();
|
||||
var responsesActivities = new List<Activity>();
|
||||
var preWrapActivities = new ConcurrentActivityList();
|
||||
var responsesActivities = new ConcurrentActivityList();
|
||||
|
||||
using var preWrapProvider = Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(preWrapSource)
|
||||
@@ -125,18 +125,19 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { }
|
||||
|
||||
// Assert — pre-wrap source emits exactly 1 span (agent ran)
|
||||
Assert.Single(preWrapActivities);
|
||||
Assert.Equal("invoke_agent", preWrapActivities[0].GetTagItem("gen_ai.operation.name"));
|
||||
var preWrapSnapshot = preWrapActivities.Snapshot();
|
||||
Assert.Single(preWrapSnapshot);
|
||||
Assert.Equal("invoke_agent", preWrapSnapshot[0].GetTagItem("gen_ai.operation.name"));
|
||||
|
||||
// ResponsesSourceName emits 0 spans — ApplyOpenTelemetry skipped wrapping the pre-instrumented agent
|
||||
Assert.DoesNotContain(responsesActivities, a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name")));
|
||||
Assert.DoesNotContain(responsesActivities.Snapshot(), a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_DefaultAgent_SpanDisplayNameContainsAgentNameAsync()
|
||||
{
|
||||
// Arrange
|
||||
var activities = new List<Activity>();
|
||||
var activities = new ConcurrentActivityList();
|
||||
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(ResponsesSourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
@@ -155,7 +156,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.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList());
|
||||
var mySpan = Assert.Single(activities.Snapshot().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);
|
||||
}
|
||||
@@ -231,4 +232,35 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
+5
-7
@@ -14,7 +14,7 @@ public class GitHubCopilotAgentTests
|
||||
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
|
||||
|
||||
private static Task<PermissionRequestResult> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
|
||||
=> Task.FromResult(new PermissionRequestResult { Kind = "approved" });
|
||||
=> Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync()
|
||||
@@ -201,11 +201,10 @@ public class GitHubCopilotAgentTests
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
OnPermissionRequest = OnPermissionRequestAsync,
|
||||
McpServers = new Dictionary<string, object>
|
||||
McpServers = new Dictionary<string, McpServerConfig>
|
||||
{
|
||||
["filesystem"] = new McpLocalServerConfig
|
||||
["filesystem"] = new McpStdioServerConfig
|
||||
{
|
||||
Type = "stdio",
|
||||
Command = "npx",
|
||||
Args = ["-y", "@modelcontextprotocol/server-filesystem", "."],
|
||||
Tools = ["*"],
|
||||
@@ -234,11 +233,10 @@ public class GitHubCopilotAgentTests
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
OnPermissionRequest = OnPermissionRequestAsync,
|
||||
McpServers = new Dictionary<string, object>
|
||||
McpServers = new Dictionary<string, McpServerConfig>
|
||||
{
|
||||
["microsoft-learn"] = new McpRemoteServerConfig
|
||||
["microsoft-learn"] = new McpHttpServerConfig
|
||||
{
|
||||
Type = "http",
|
||||
Url = "https://learn.microsoft.com/api/mcp",
|
||||
Tools = ["*"],
|
||||
},
|
||||
|
||||
+2
-2
@@ -111,7 +111,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
|
||||
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
|
||||
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
var mcpServers = new Dictionary<string, object> { ["server1"] = new McpLocalServerConfig() };
|
||||
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
|
||||
|
||||
var source = new SessionConfig
|
||||
{
|
||||
@@ -162,7 +162,7 @@ public sealed class GitHubCopilotAgentTests
|
||||
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
|
||||
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
|
||||
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
|
||||
var mcpServers = new Dictionary<string, object> { ["server1"] = new McpLocalServerConfig() };
|
||||
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
|
||||
|
||||
var source = new SessionConfig
|
||||
{
|
||||
|
||||
@@ -216,10 +216,12 @@ class AnthropicSettings(TypedDict, total=False):
|
||||
Keys:
|
||||
api_key: The Anthropic API key.
|
||||
chat_model: The Anthropic chat model.
|
||||
base_url: Optional base URL for the Anthropic API endpoint.
|
||||
"""
|
||||
|
||||
api_key: SecretString | None
|
||||
chat_model: str | None
|
||||
base_url: str | None
|
||||
|
||||
|
||||
class RawAnthropicClient(
|
||||
@@ -248,6 +250,7 @@ class RawAnthropicClient(
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
anthropic_client: AnthropicAsyncClient | None = None,
|
||||
additional_beta_flags: list[str] | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
@@ -259,6 +262,8 @@ class RawAnthropicClient(
|
||||
Keyword Args:
|
||||
api_key: The Anthropic API key to use for authentication.
|
||||
model: The model to use.
|
||||
base_url: Optional base URL for the Anthropic API endpoint. Useful for Foundry or
|
||||
other compatible deployments. Falls back to ``ANTHROPIC_BASE_URL`` env variable.
|
||||
anthropic_client: An existing Anthropic client to use. If not provided, one will be created.
|
||||
This can be used to further configure the client before passing it in.
|
||||
For instance if you need to set a different base_url for testing or private deployments.
|
||||
@@ -284,6 +289,13 @@ class RawAnthropicClient(
|
||||
api_key="your_anthropic_api_key",
|
||||
)
|
||||
|
||||
# Or with a custom base URL (e.g. for Foundry-compatible endpoints)
|
||||
client = RawAnthropicClient(
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
api_key="your_anthropic_api_key",
|
||||
base_url="https://custom-anthropic-endpoint.com",
|
||||
)
|
||||
|
||||
# Or loading from a .env file
|
||||
client = RawAnthropicClient(env_file_path="path/to/.env")
|
||||
|
||||
@@ -316,12 +328,14 @@ class RawAnthropicClient(
|
||||
env_prefix="ANTHROPIC_",
|
||||
api_key=api_key,
|
||||
chat_model=model,
|
||||
base_url=base_url,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
|
||||
api_key_secret = anthropic_settings.get("api_key")
|
||||
model_setting = anthropic_settings.get("chat_model")
|
||||
base_url_setting = anthropic_settings.get("base_url")
|
||||
|
||||
if anthropic_client is None:
|
||||
if api_key_secret is None:
|
||||
@@ -332,6 +346,7 @@ class RawAnthropicClient(
|
||||
|
||||
anthropic_client = AsyncAnthropic(
|
||||
api_key=api_key_secret.get_secret_value(),
|
||||
base_url=base_url_setting,
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
|
||||
@@ -1409,6 +1424,7 @@ class AnthropicClient(
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
anthropic_client: AnthropicAsyncClient | None = None,
|
||||
additional_beta_flags: list[str] | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
@@ -1422,6 +1438,8 @@ class AnthropicClient(
|
||||
Keyword Args:
|
||||
api_key: The Anthropic API key to use for authentication.
|
||||
model: The model to use.
|
||||
base_url: Optional base URL for the Anthropic API endpoint. Useful for Foundry or
|
||||
other compatible deployments. Falls back to ``ANTHROPIC_BASE_URL`` env variable.
|
||||
anthropic_client: An existing Anthropic client to use. If not provided, one will be created.
|
||||
This can be used to further configure the client before passing it in.
|
||||
For instance if you need to set a different base_url for testing or private deployments.
|
||||
@@ -1448,6 +1466,13 @@ class AnthropicClient(
|
||||
api_key="your_anthropic_api_key",
|
||||
)
|
||||
|
||||
# Or with a custom base URL (e.g. for Foundry-compatible endpoints)
|
||||
client = AnthropicClient(
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
api_key="your_anthropic_api_key",
|
||||
base_url="https://custom-anthropic-endpoint.com",
|
||||
)
|
||||
|
||||
# Or loading from a .env file
|
||||
client = AnthropicClient(env_file_path="path/to/.env")
|
||||
|
||||
@@ -1477,6 +1502,7 @@ class AnthropicClient(
|
||||
super().__init__(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
anthropic_client=anthropic_client,
|
||||
additional_beta_flags=additional_beta_flags,
|
||||
additional_properties=additional_properties,
|
||||
|
||||
@@ -149,6 +149,108 @@ def test_anthropic_client_init_auto_create_client(
|
||||
assert client.model == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"]
|
||||
|
||||
|
||||
def test_anthropic_client_init_with_base_url(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test AnthropicClient accepts a base_url and passes it to the underlying AsyncAnthropic client."""
|
||||
custom_url = "https://custom-anthropic-endpoint.com"
|
||||
client = AnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
base_url=custom_url,
|
||||
)
|
||||
|
||||
assert custom_url in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
def test_raw_anthropic_client_init_with_base_url(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test RawAnthropicClient accepts a base_url and passes it to the underlying AsyncAnthropic client."""
|
||||
custom_url = "https://custom-anthropic-endpoint.com"
|
||||
client = RawAnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
base_url=custom_url,
|
||||
)
|
||||
|
||||
assert custom_url in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_anthropic_client_init_base_url_from_env(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test AnthropicClient picks up base_url from ANTHROPIC_BASE_URL env variable when not passed explicitly."""
|
||||
client = AnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
)
|
||||
|
||||
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_raw_anthropic_client_init_base_url_from_env(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test RawAnthropicClient picks up base_url from ANTHROPIC_BASE_URL env variable when not passed explicitly."""
|
||||
client = RawAnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
)
|
||||
|
||||
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_anthropic_client_init_explicit_base_url_wins_over_env(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that an explicit base_url kwarg takes priority over ANTHROPIC_BASE_URL env variable."""
|
||||
explicit_url = "https://explicit-endpoint.example.com"
|
||||
client = AnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
base_url=explicit_url,
|
||||
)
|
||||
|
||||
assert explicit_url in str(client.anthropic_client.base_url)
|
||||
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] not in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_raw_anthropic_client_init_explicit_base_url_wins_over_env(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that an explicit base_url kwarg takes priority over ANTHROPIC_BASE_URL env variable."""
|
||||
explicit_url = "https://explicit-endpoint.example.com"
|
||||
client = RawAnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
base_url=explicit_url,
|
||||
)
|
||||
|
||||
assert explicit_url in str(client.anthropic_client.base_url)
|
||||
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] not in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
def test_anthropic_client_init_missing_api_key() -> None:
|
||||
"""Test AnthropicClient initialization when API key is missing."""
|
||||
with patch("agent_framework_anthropic._chat_client.load_settings") as mock_load:
|
||||
|
||||
@@ -352,8 +352,8 @@ __all__ = [
|
||||
"ContinuationToken",
|
||||
"ConversationSplit",
|
||||
"ConversationSplitter",
|
||||
"Default",
|
||||
"DeduplicatingSkillsSource",
|
||||
"Default",
|
||||
"DelegatingSkillsSource",
|
||||
"Edge",
|
||||
"EdgeCondition",
|
||||
|
||||
@@ -158,6 +158,22 @@ 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
|
||||
|
||||
|
||||
@@ -627,6 +643,17 @@ 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)
|
||||
@@ -655,14 +682,23 @@ class MCPTool:
|
||||
if not self.session:
|
||||
try:
|
||||
transport = await self._exit_stack.enter_async_context(self.get_mcp_client())
|
||||
except Exception as ex:
|
||||
await self._safe_close_exit_stack()
|
||||
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
|
||||
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}"
|
||||
raise ToolException(error_msg, inner_exception=ex) from 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
|
||||
try:
|
||||
try:
|
||||
from mcp import types
|
||||
@@ -692,16 +728,21 @@ class MCPTool:
|
||||
sampling_capabilities=sampling_capabilities,
|
||||
)
|
||||
)
|
||||
except Exception as ex:
|
||||
await self._safe_close_exit_stack()
|
||||
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)
|
||||
raise ToolException(
|
||||
message="Failed to create MCP session. Please check your configuration.",
|
||||
inner_exception=ex,
|
||||
message=session_error_msg,
|
||||
inner_exception=ex if isinstance(ex, Exception) else None,
|
||||
) from ex
|
||||
try:
|
||||
await session.initialize()
|
||||
except Exception as ex:
|
||||
await self._safe_close_exit_stack()
|
||||
except (Exception, asyncio.CancelledError) as ex:
|
||||
if await self._close_and_check_cancelled(ex):
|
||||
raise
|
||||
# Provide context about initialization failure
|
||||
command = getattr(self, "command", None)
|
||||
if command:
|
||||
@@ -710,7 +751,9 @@ class MCPTool:
|
||||
error_msg = f"MCP server '{full_command}' failed to initialize: {ex}"
|
||||
else:
|
||||
error_msg = f"MCP server failed to initialize: {ex}"
|
||||
raise ToolException(error_msg, inner_exception=ex) from 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
|
||||
self.session = session
|
||||
elif self.session._request_id == 0: # type: ignore[attr-defined]
|
||||
# If the session is not initialized, we need to reinitialize it
|
||||
|
||||
@@ -446,14 +446,10 @@ class FileSkillScript(SkillScript):
|
||||
"""
|
||||
if not isinstance(skill, FileSkill):
|
||||
raise TypeError(
|
||||
f"File-based script '{self.name}' requires a FileSkill "
|
||||
f"but received '{type(skill).__name__}'."
|
||||
f"File-based script '{self.name}' requires a FileSkill but received '{type(skill).__name__}'."
|
||||
)
|
||||
if self._runner is None:
|
||||
raise ValueError(
|
||||
f"Script '{self.name}' requires a runner. "
|
||||
"Provide a script_runner for file-based scripts."
|
||||
)
|
||||
raise ValueError(f"Script '{self.name}' requires a runner. Provide a script_runner for file-based scripts.")
|
||||
result = self._runner(skill, self, args)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
@@ -570,8 +566,7 @@ def _validate_skill_description(name: str, description: str) -> None:
|
||||
raise ValueError("Skill description cannot be empty.")
|
||||
if len(description) > MAX_DESCRIPTION_LENGTH:
|
||||
raise ValueError(
|
||||
f"Skill '{name}' has an invalid description: "
|
||||
f"Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
|
||||
f"Skill '{name}' has an invalid description: Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
|
||||
)
|
||||
|
||||
|
||||
@@ -1993,10 +1988,7 @@ class FileSkillsSource(SkillsSource):
|
||||
raise ValueError(f"Resource file '{resource_name}' not found in skill directory '{skill_dir}'.")
|
||||
|
||||
if FileSkillsSource._has_symlink_in_path(resource_full_path, root_directory_path):
|
||||
raise ValueError(
|
||||
f"Resource file '{resource_name}' "
|
||||
"has a symlink in its path; symlinks are not allowed."
|
||||
)
|
||||
raise ValueError(f"Resource file '{resource_name}' has a symlink in its path; symlinks are not allowed.")
|
||||
|
||||
return resource_full_path
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# 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
|
||||
@@ -27,6 +29,7 @@ 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
|
||||
@@ -2176,6 +2179,7 @@ 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__)
|
||||
|
||||
|
||||
@@ -2264,6 +2268,282 @@ 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"}
|
||||
|
||||
@@ -1190,7 +1190,9 @@ class TestSkillsProviderCodeSkill:
|
||||
|
||||
provider = SkillsProvider([skill])
|
||||
await _init_provider(provider)
|
||||
result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "get_user_data", auth_token="abc")
|
||||
result = await provider._read_skill_resource(
|
||||
_raw_skills(provider), "prog-skill", "get_user_data", auth_token="abc"
|
||||
)
|
||||
assert result == "data with token=abc"
|
||||
|
||||
async def test_read_callable_resource_without_kwargs_ignores_extra_args(self) -> None:
|
||||
@@ -2059,6 +2061,7 @@ class TestSkillResourceRead:
|
||||
|
||||
async def test_read_async_function(self) -> None:
|
||||
"""read() awaits an async function and returns its result."""
|
||||
|
||||
async def get_data() -> str:
|
||||
return "async result"
|
||||
|
||||
@@ -2068,6 +2071,7 @@ class TestSkillResourceRead:
|
||||
|
||||
async def test_read_function_with_kwargs(self) -> None:
|
||||
"""read() forwards kwargs to functions that accept them."""
|
||||
|
||||
def get_config(**kwargs: Any) -> str:
|
||||
return f"user={kwargs.get('user_id')}"
|
||||
|
||||
@@ -2077,6 +2081,7 @@ class TestSkillResourceRead:
|
||||
|
||||
async def test_read_async_function_with_kwargs(self) -> None:
|
||||
"""read() forwards kwargs to async functions that accept them."""
|
||||
|
||||
async def get_config(**kwargs: Any) -> str:
|
||||
return f"user={kwargs.get('user_id')}"
|
||||
|
||||
@@ -2086,6 +2091,7 @@ class TestSkillResourceRead:
|
||||
|
||||
async def test_read_function_without_kwargs_ignores_extra(self) -> None:
|
||||
"""read() does not pass kwargs to functions that don't accept them."""
|
||||
|
||||
def simple() -> str:
|
||||
return "fixed"
|
||||
|
||||
@@ -2095,6 +2101,7 @@ class TestSkillResourceRead:
|
||||
|
||||
async def test_read_function_raises_propagates(self) -> None:
|
||||
"""read() propagates exceptions from the function."""
|
||||
|
||||
def failing() -> str:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
@@ -2747,6 +2754,7 @@ class TestSkillsProviderFactories:
|
||||
|
||||
async def test_code_script_returns_object(self) -> None:
|
||||
"""Code-defined scripts can return non-string objects."""
|
||||
|
||||
def returns_dict() -> dict:
|
||||
return {"status": "ok", "value": 42}
|
||||
|
||||
@@ -2855,8 +2863,8 @@ class TestSkillsProviderFactories:
|
||||
|
||||
provider = SkillsProvider([skill])
|
||||
await _init_provider(provider)
|
||||
result = await provider._run_skill_script(_raw_skills(provider),
|
||||
"my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value"
|
||||
result = await provider._run_skill_script(
|
||||
_raw_skills(provider), "my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value"
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
@@ -2946,6 +2954,7 @@ class TestSkillsProviderFactories:
|
||||
|
||||
async def test_code_script_exception_returns_error(self) -> None:
|
||||
"""A code script function that raises should return an error string."""
|
||||
|
||||
def failing_script() -> str:
|
||||
raise RuntimeError("Something went wrong")
|
||||
|
||||
@@ -3170,6 +3179,7 @@ class TestLoadSkillWithScripts:
|
||||
|
||||
async def test_code_skill_scripts_element_contains_parameters(self) -> None:
|
||||
"""Scripts XML includes parameters schema when the function has typed parameters."""
|
||||
|
||||
def analyze(query: str, limit: int = 10) -> str:
|
||||
return "result"
|
||||
|
||||
@@ -3755,9 +3765,7 @@ class TestSourceComposition:
|
||||
)
|
||||
(skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
|
||||
|
||||
source = DeduplicatingSkillsSource(
|
||||
FileSkillsSource(str(tmp_path), script_runner=_noop_script_runner)
|
||||
)
|
||||
source = DeduplicatingSkillsSource(FileSkillsSource(str(tmp_path), script_runner=_noop_script_runner))
|
||||
provider = SkillsProvider(source)
|
||||
await _init_provider(provider)
|
||||
assert "my-skill" in _ctx(provider)[0]
|
||||
@@ -3798,9 +3806,7 @@ class TestSourceComposition:
|
||||
call_log.append("source")
|
||||
return "source"
|
||||
|
||||
source = DeduplicatingSkillsSource(
|
||||
FileSkillsSource(str(tmp_path), script_runner=source_runner)
|
||||
)
|
||||
source = DeduplicatingSkillsSource(FileSkillsSource(str(tmp_path), script_runner=source_runner))
|
||||
provider = SkillsProvider(source)
|
||||
await _init_provider(provider)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user