diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index a07bde035b..a91ae973da 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -98,7 +98,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_GitHubCopilot/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_GitHubCopilot/Program.cs index b233259dcc..149cbbe029 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_GitHubCopilot/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_GitHubCopilot/Program.cs @@ -12,7 +12,9 @@ static Task 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 }); } diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index bbebd7a312..c8a4ffe028 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -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? attachments, tempDir) = await ProcessDataContentAttachmentsAsync( + (List? 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? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync( + private static async Task<(List? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync( IEnumerable messages, CancellationToken cancellationToken) { - List? attachments = null; + List? 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) diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs index 855e9b4037..f8b5210c89 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs @@ -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 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 + McpServers = new Dictionary { - ["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 + McpServers = new Dictionary { - ["microsoft-learn"] = new McpRemoteServerConfig + ["microsoft-learn"] = new McpHttpServerConfig { - Type = "http", Url = "https://learn.microsoft.com/api/mcp", Tools = ["*"], }, diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs index 52ea0026dc..e2d63b4fc5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs @@ -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 { ["server1"] = new McpLocalServerConfig() }; + var mcpServers = new Dictionary { ["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 { ["server1"] = new McpLocalServerConfig() }; + var mcpServers = new Dictionary { ["server1"] = new McpStdioServerConfig() }; var source = new SessionConfig { diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 1d1b978dd2..98c181f152 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -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, diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 945e5356a4..0cfec3423c 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -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: diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index eb439c3543..4592f8c716 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -352,8 +352,8 @@ __all__ = [ "ContinuationToken", "ConversationSplit", "ConversationSplitter", - "Default", "DeduplicatingSkillsSource", + "Default", "DelegatingSkillsSource", "Edge", "EdgeCondition", diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 082c6f1b69..06612b4df0 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -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 diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 36906d55b8..8e8c6a8aed 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -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)