diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index c8a4ffe028..79a9f4e1b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -181,6 +181,14 @@ protected override async IAsyncEnumerable RunCoreStreamingA channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage)); break; + case ToolExecutionStartEvent toolStart: + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(toolStart)); + break; + + case ToolExecutionCompleteEvent toolComplete: + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(toolComplete)); + break; + case AssistantUsageEvent usageEvent: channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(usageEvent)); break; @@ -362,6 +370,41 @@ internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent }; } + private AgentResponseUpdate ConvertToAgentResponseUpdate(ToolExecutionStartEvent toolStart) + { + Dictionary? arguments = ParseToolArguments(toolStart.Data?.Arguments as string); + + FunctionCallContent content = new(toolStart.Data?.ToolCallId ?? string.Empty, toolStart.Data?.ToolName ?? string.Empty) + { + Arguments = arguments, + RawRepresentation = toolStart + }; + + return new AgentResponseUpdate(ChatRole.Assistant, [content]) + { + AgentId = this.Id, + CreatedAt = toolStart.Timestamp + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(ToolExecutionCompleteEvent toolComplete) + { + string? result = toolComplete.Data?.Success == true + ? toolComplete.Data?.Result?.Content + : toolComplete.Data?.Error?.Message; + + FunctionResultContent content = new(toolComplete.Data?.ToolCallId ?? string.Empty, result) + { + RawRepresentation = toolComplete + }; + + return new AgentResponseUpdate(ChatRole.Tool, [content]) + { + AgentId = this.Id, + CreatedAt = toolComplete.Timestamp + }; + } + private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent) { UsageDetails usageDetails = new() @@ -415,6 +458,45 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usa return additionalCounts; } + private static Dictionary? ParseToolArguments(string? argumentsJson) + { + if (string.IsNullOrEmpty(argumentsJson)) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(argumentsJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return new Dictionary { ["_raw"] = argumentsJson }; + } + + var result = new Dictionary(); + foreach (var property in doc.RootElement.EnumerateObject()) + { + result[property.Name] = property.Value.ValueKind switch + { + JsonValueKind.String => property.Value.GetString(), + JsonValueKind.Number => property.Value.GetRawText() is string raw && double.TryParse(raw, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double d) ? d : (object)property.Value.GetRawText(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => property.Value.GetRawText() + }; + } + + return result; + } + catch (Exception) + { + // Gracefully fall back for any parsing failure (malformed JSON, non-object root, + // unexpected element types, etc.) to avoid breaking the streaming pipeline. + return new Dictionary { ["_raw"] = argumentsJson }; + } + } + private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent) { // Handle arbitrary events by storing as RawRepresentation diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/ToolExecutionEventProjectionTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/ToolExecutionEventProjectionTests.cs new file mode 100644 index 0000000000..332bfc7d8f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/ToolExecutionEventProjectionTests.cs @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests; + +/// +/// Tests verifying that ToolExecutionStartEvent and ToolExecutionCompleteEvent are correctly +/// projected into FunctionCallContent and FunctionResultContent respectively. +/// +public sealed class ToolExecutionEventProjectionTests +{ + [Fact] + public void ToolExecutionStartEvent_ProducesFunctionCallContent() + { + // Arrange + var toolStartEvent = new ToolExecutionStartEvent + { + Data = new ToolExecutionStartData + { + ToolCallId = "call_abc123", + ToolName = "msgraph-admin__get_users", + Arguments = "{\"top\": 10}", + McpServerName = "msgraph-admin", + McpToolName = "get_users" + } + }; + + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "test-agent", tools: null); + + // Act + AgentResponseUpdate result = InvokeConvert(agent, toolStartEvent); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.Equal(ChatRole.Assistant, result.Role); + + var content = Assert.IsType(result.Contents[0]); + Assert.Equal("call_abc123", content.CallId); + Assert.Equal("msgraph-admin__get_users", content.Name); + Assert.NotNull(content.Arguments); + Assert.Equal(10.0, content.Arguments["top"]); + Assert.Same(toolStartEvent, content.RawRepresentation); + } + + [Fact] + public void ToolExecutionStartEvent_WithNullArguments_ProducesFunctionCallContentWithNullArguments() + { + // Arrange + var toolStartEvent = new ToolExecutionStartEvent + { + Data = new ToolExecutionStartData + { + ToolCallId = "call_noargs", + ToolName = "ping" + } + }; + + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "test-agent", tools: null); + + // Act + AgentResponseUpdate result = InvokeConvert(agent, toolStartEvent); + + // Assert + var content = Assert.IsType(result.Contents[0]); + Assert.Equal("call_noargs", content.CallId); + Assert.Equal("ping", content.Name); + Assert.Null(content.Arguments); + } + + [Fact] + public void ToolExecutionCompleteEvent_Success_ProducesFunctionResultContent() + { + // Arrange + var toolCompleteEvent = new ToolExecutionCompleteEvent + { + Data = new ToolExecutionCompleteData + { + ToolCallId = "call_abc123", + Success = true, + Result = new ToolExecutionCompleteResult + { + Content = "{\"users\":[{\"displayName\":\"Alice\",\"mail\":\"alice@contoso.com\"}]}" + } + } + }; + + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "test-agent", tools: null); + + // Act + AgentResponseUpdate result = InvokeConvert(agent, toolCompleteEvent); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.Equal(ChatRole.Tool, result.Role); + + var content = Assert.IsType(result.Contents[0]); + Assert.Equal("call_abc123", content.CallId); + Assert.Same(toolCompleteEvent, content.RawRepresentation); + } + + [Fact] + public void ToolExecutionCompleteEvent_Error_ProducesFunctionResultContentWithErrorMessage() + { + // Arrange + var toolCompleteEvent = new ToolExecutionCompleteEvent + { + Data = new ToolExecutionCompleteData + { + ToolCallId = "call_def456", + Success = false, + Error = new ToolExecutionCompleteError + { + Message = "Permission denied: insufficient scope for users.read" + } + } + }; + + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "test-agent", tools: null); + + // Act + AgentResponseUpdate result = InvokeConvert(agent, toolCompleteEvent); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.Equal(ChatRole.Tool, result.Role); + + var content = Assert.IsType(result.Contents[0]); + Assert.Equal("call_def456", content.CallId); + } + + [Fact] + public void ToolExecutionStartEvent_ParsesComplexArguments() + { + // Arrange + var toolStartEvent = new ToolExecutionStartEvent + { + Data = new ToolExecutionStartData + { + ToolCallId = "call_complex", + ToolName = "sn-query-table__getIncidents", + Arguments = "{\"filter\": \"state=1\", \"limit\": 50, \"active\": true}" + } + }; + + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "test-agent", tools: null); + + // Act + AgentResponseUpdate result = InvokeConvert(agent, toolStartEvent); + + // Assert + var content = Assert.IsType(result.Contents[0]); + Assert.Equal(50.0, content.Arguments!["limit"]); + Assert.Equal(true, content.Arguments!["active"]); + } + + [Fact] + public void ToolExecutionCompleteEvent_NullData_ProducesFunctionResultContentWithDefaults() + { + // Arrange + var toolCompleteEvent = new ToolExecutionCompleteEvent + { + Data = null! + }; + + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "test-agent", tools: null); + + // Act + AgentResponseUpdate result = InvokeConvert(agent, toolCompleteEvent); + + // Assert + var content = Assert.IsType(result.Contents[0]); + Assert.Equal(string.Empty, content.CallId); + Assert.Null(content.Result); + } + + [Theory] + [InlineData("null")] + [InlineData("[1, 2, 3]")] + [InlineData("\"just a string\"")] + public void ToolExecutionStartEvent_NonObjectArguments_FallsBackToRawDictionary(string arguments) + { + // Arrange + var toolStartEvent = new ToolExecutionStartEvent + { + Data = new ToolExecutionStartData + { + ToolCallId = "call_nonobj", + ToolName = "some_tool", + Arguments = arguments + } + }; + + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "test-agent", tools: null); + + // Act + AgentResponseUpdate result = InvokeConvert(agent, toolStartEvent); + + // Assert + var content = Assert.IsType(result.Contents[0]); + Assert.NotNull(content.Arguments); + Assert.Equal(arguments, content.Arguments["_raw"]); + } + + [Fact] + public void ToolExecutionStartEvent_MalformedJson_FallsBackToRawDictionary() + { + // Arrange + var toolStartEvent = new ToolExecutionStartEvent + { + Data = new ToolExecutionStartData + { + ToolCallId = "call_malformed", + ToolName = "some_tool", + Arguments = "{not valid json" + } + }; + + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "test-agent", tools: null); + + // Act + AgentResponseUpdate result = InvokeConvert(agent, toolStartEvent); + + // Assert + var content = Assert.IsType(result.Contents[0]); + Assert.NotNull(content.Arguments); + Assert.Equal("{not valid json", content.Arguments["_raw"]); + } + + [Fact] + public void ToolExecutionStartEvent_OutOfRangeNumber_DoesNotThrow() + { + // Arrange: number exceeding double range should not break streaming + var toolStartEvent = new ToolExecutionStartEvent + { + Data = new ToolExecutionStartData + { + ToolCallId = "call_bignum", + ToolName = "some_tool", + Arguments = "{\"value\": 1e999}" + } + }; + + CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false }); + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "test-agent", tools: null); + + // Act - should not throw + AgentResponseUpdate result = InvokeConvert(agent, toolStartEvent); + + // Assert + var content = Assert.IsType(result.Contents[0]); + Assert.NotNull(content.Arguments); + } + + /// + /// Invokes the appropriate ConvertToAgentResponseUpdate method via reflection. + /// + private static AgentResponseUpdate InvokeConvert(GitHubCopilotAgent agent, SessionEvent sessionEvent) + { + MethodInfo? method = typeof(GitHubCopilotAgent) + .GetMethod( + "ConvertToAgentResponseUpdate", + BindingFlags.Instance | BindingFlags.NonPublic, + null, + [sessionEvent.GetType()], + null); + + // Fall back to the SessionEvent overload if no specific overload exists + method ??= typeof(GitHubCopilotAgent) + .GetMethod( + "ConvertToAgentResponseUpdate", + BindingFlags.Instance | BindingFlags.NonPublic, + null, + [typeof(SessionEvent)], + null); + + Assert.NotNull(method); + + return (AgentResponseUpdate)method!.Invoke(agent, [sessionEvent])!; + } +} diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 683302b13a..5e313f20d9 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -2134,9 +2134,7 @@ async def _run_script( ), FunctionTool( name="read_skill_resource", - description=( - "Reads a resource associated with a skill, such as references, assets, or dynamic data." - ), + description=("Reads a resource associated with a skill, such as references, assets, or dynamic data."), func=_read_resource, input_model={ "type": "object", @@ -2173,8 +2171,7 @@ async def _run_script( "type": "object", "additionalProperties": True, "description": ( - "Named arguments as key-value pairs " - '(e.g. {"length": 24, "uppercase": true}).' + 'Named arguments as key-value pairs (e.g. {"length": 24, "uppercase": true}).' ), }, { diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 17fb2cf5ce..31f679c367 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -4086,8 +4086,8 @@ async def test_full_skill_content_contains_scripts(self) -> None: async def test_content_is_cached(self) -> None: skill = _MinimalClassSkill() - content1 = (await skill.get_content()) - content2 = (await skill.get_content()) + content1 = await skill.get_content() + content2 = await skill.get_content() assert content1 is content2 def test_resources_are_lazy_cached(self) -> None: @@ -5587,8 +5587,8 @@ class TestInlineSkillContentCaching: async def test_content_cached_after_first_access(self) -> None: """InlineSkill.content returns the same object on subsequent accesses.""" skill = InlineSkill(frontmatter=SkillFrontmatter(name="test-skill", description="Test"), instructions="Body") - first = (await skill.get_content()) - second = (await skill.get_content()) + first = await skill.get_content() + second = await skill.get_content() assert first is second # Same object (cached) assert "test-skill" in first diff --git a/python/samples/02-agents/harness/harness_research.py b/python/samples/02-agents/harness/harness_research.py index f1cb66228a..977c26f049 100644 --- a/python/samples/02-agents/harness/harness_research.py +++ b/python/samples/02-agents/harness/harness_research.py @@ -109,7 +109,10 @@ async def main() -> None: print(f"\n [calling tool: {content.name}]", flush=True) print(" ", end="", flush=True) # Show web search activity when the result arrives with action details. - elif content.type in ("search_tool_call", "search_tool_result") and getattr(content, "tool_name", None) == "web_search": + elif ( + content.type in ("search_tool_call", "search_tool_result") + and getattr(content, "tool_name", None) == "web_search" + ): action = None if content.type == "search_tool_result" and isinstance(content.result, dict): action = content.result.get("action", {}) diff --git a/python/uv.lock b/python/uv.lock index a67c495e62..3ef58900c5 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -606,7 +606,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" }, ] [[package]]