Compare commits

..
Author SHA1 Message Date
Jacob Alber 8a1ad0199a fix: whitespace formatting 2026-05-07 16:44:17 +00:00
Jacob Alber cbc471dc18 fix sample project 2026-05-07 16:41:41 +00:00
Jacob Alber 6ddb74f8b3 fix: Update for breaking changes in Github.Copilot.SDK 2026-05-07 16:25:15 +00:00
2f93971a59 Fix formatting
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 12:05:32 -04:00
Jacob Alber 93ccf52c39 feat: Update Github Copilot SDK to 1.0.0-beta.2 2026-05-07 11:53:19 -04:00
10 changed files with 46 additions and 595 deletions
+1 -9
View File
@@ -379,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
@@ -396,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
@@ -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."
}
@@ -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();
}
}
@@ -216,12 +216,10 @@ 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(
@@ -250,7 +248,6 @@ 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,
@@ -262,8 +259,6 @@ 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.
@@ -289,13 +284,6 @@ 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")
@@ -328,14 +316,12 @@ 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:
@@ -346,7 +332,6 @@ 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()},
)
@@ -1424,7 +1409,6 @@ 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,
@@ -1438,8 +1422,6 @@ 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.
@@ -1466,13 +1448,6 @@ 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")
@@ -1502,7 +1477,6 @@ 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,108 +149,6 @@ 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",
"DeduplicatingSkillsSource",
"Default",
"DeduplicatingSkillsSource",
"DelegatingSkillsSource",
"Edge",
"EdgeCondition",
+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
@@ -446,10 +446,14 @@ class FileSkillScript(SkillScript):
"""
if not isinstance(skill, FileSkill):
raise TypeError(
f"File-based script '{self.name}' requires a FileSkill but received '{type(skill).__name__}'."
f"File-based script '{self.name}' requires a FileSkill "
f"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
@@ -566,7 +570,8 @@ 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: Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
f"Skill '{name}' has an invalid description: "
f"Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
)
@@ -1988,7 +1993,10 @@ 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
-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"}
+9 -15
View File
@@ -1190,9 +1190,7 @@ 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:
@@ -2061,7 +2059,6 @@ 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"
@@ -2071,7 +2068,6 @@ 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')}"
@@ -2081,7 +2077,6 @@ 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')}"
@@ -2091,7 +2086,6 @@ 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"
@@ -2101,7 +2095,6 @@ class TestSkillResourceRead:
async def test_read_function_raises_propagates(self) -> None:
"""read() propagates exceptions from the function."""
def failing() -> str:
raise RuntimeError("boom")
@@ -2754,7 +2747,6 @@ 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}
@@ -2863,8 +2855,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
@@ -2954,7 +2946,6 @@ 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")
@@ -3179,7 +3170,6 @@ 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"
@@ -3765,7 +3755,9 @@ 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]
@@ -3806,7 +3798,9 @@ 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)