Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions dotnet/src/Agents/Abstractions/AIAgent/SemanticKernelAIAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using MAAI = Microsoft.Agents.AI;
using MEAI = Microsoft.Extensions.AI;

namespace Microsoft.SemanticKernel.Agents;

Expand Down Expand Up @@ -78,7 +79,20 @@ public override MAAI.AgentThread DeserializeThread(JsonElement serializedThread,
{
OnIntermediateMessage = (msg) =>
{
responseMessages.Add(msg.ToChatMessage());
// As a backwards compatibility measure, ChatCompletionService inserts the function result
// as a text message followed by a function result message. If we detect that pattern,
// we must remove the text message to avoid the function result showing up in the user output.
var chatMessage = msg.ToChatMessage();
if (chatMessage.Role == ChatRole.Tool
&& chatMessage.Contents.Count == 2
&& chatMessage.Contents[0] is MEAI.TextContent textContent
&& chatMessage.Contents[1] is MEAI.FunctionResultContent functionResultContent
&& textContent.Text == functionResultContent.Result?.ToString())
{
chatMessage.Contents.RemoveAt(0);
}

responseMessages.Add(chatMessage);
return Task.CompletedTask;
}
};
Expand All @@ -88,8 +102,6 @@ public override MAAI.AgentThread DeserializeThread(JsonElement serializedThread,
await foreach (var responseItem in this._innerAgent.InvokeAsync(messages.Select(x => x.ToChatMessageContent()).ToList(), typedThread.InnerThread, invokeOptions, cancellationToken).ConfigureAwait(false))
{
lastResponseItem = responseItem;
lastResponseMessage = responseItem.Message.ToChatMessage();
responseMessages.Add(lastResponseMessage);
}

return new MAAI.AgentRunResponse(responseMessages)
Expand Down
5 changes: 5 additions & 0 deletions dotnet/src/Agents/OpenAI/OpenAIResponseAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ public override async IAsyncEnumerable<AgentResponseItem<ChatMessageContent>> In
// Notify the thread of new messages and return them to the caller.
await foreach (var result in invokeResults.ConfigureAwait(false))
{
if (options?.OnIntermediateMessage is not null)
{
await options.OnIntermediateMessage(result).ConfigureAwait(false);
}

await this.NotifyThreadOfNewMessage(agentThread, result, cancellationToken).ConfigureAwait(false);
yield return new(result, agentThread);
}
Expand Down
17 changes: 13 additions & 4 deletions dotnet/src/Agents/OpenAI/OpenAIResponseAgentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.SemanticKernel.ChatCompletion;
using MAAI = Microsoft.Agents.AI;

namespace Microsoft.SemanticKernel.Agents.OpenAI;
Expand All @@ -19,11 +20,19 @@ public static class OpenAIResponseAgentExtensions
[Experimental("SKEXP0110")]
public static MAAI.AIAgent AsAIAgent(this OpenAIResponseAgent responseAgent)
=> responseAgent.AsAIAgent(
() => new OpenAIResponseAgentThread(responseAgent.Client),
() => responseAgent.StoreEnabled ? new OpenAIResponseAgentThread(responseAgent.Client) : new ChatHistoryAgentThread(),
(json, options) =>
{
var agentId = JsonSerializer.Deserialize<string>(json);
return agentId is null ? new OpenAIResponseAgentThread(responseAgent.Client) : new OpenAIResponseAgentThread(responseAgent.Client, agentId);
if (responseAgent.StoreEnabled)
{
var agentId = JsonSerializer.Deserialize<string>(json);
return agentId is null ? new OpenAIResponseAgentThread(responseAgent.Client) : new OpenAIResponseAgentThread(responseAgent.Client, agentId);
}

var chatHistory = JsonSerializer.Deserialize<ChatHistory>(json);
return chatHistory is null ? new ChatHistoryAgentThread() : new ChatHistoryAgentThread(chatHistory);
},
(thread, options) => JsonSerializer.SerializeToElement((thread as OpenAIResponseAgentThread)?.Id));
(thread, options) => responseAgent.StoreEnabled
? JsonSerializer.SerializeToElement((thread as OpenAIResponseAgentThread)?.Id)
: JsonSerializer.SerializeToElement((thread as ChatHistoryAgentThread)?.ChatHistory));
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -12,6 +13,7 @@
using Moq;
using Xunit;
using MAAI = Microsoft.Agents.AI;
using MEAI = Microsoft.Extensions.AI;

namespace SemanticKernel.Agents.UnitTests.AIAgent;

Expand Down Expand Up @@ -196,12 +198,17 @@ public async Task Run_CallsInnerAgentAsync()
It.IsAny<AgentThread>(),
It.IsAny<AgentInvokeOptions>(),
It.IsAny<CancellationToken>()))
.Returns(GetAsyncEnumerable());
.Returns(MockInvokeAsync);
var adapter = new SemanticKernelAIAgent(agentMock.Object, () => Mock.Of<AgentThread>(), (e, o) => Mock.Of<AgentThread>(), (t, o) => default);

async IAsyncEnumerable<AgentResponseItem<ChatMessageContent>> GetAsyncEnumerable()
async IAsyncEnumerable<AgentResponseItem<ChatMessageContent>> MockInvokeAsync(ICollection<ChatMessageContent> messages,
AgentThread? thread = null,
AgentInvokeOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
yield return new AgentResponseItem<ChatMessageContent>(new ChatMessageContent(AuthorRole.Assistant, "Final response"), innerThread);
var message = new ChatMessageContent(AuthorRole.Assistant, "Final response");
await options!.OnIntermediateMessage!.Invoke(message);
yield return new AgentResponseItem<ChatMessageContent>(message, innerThread);
}

var thread = new SemanticKernelAIAgentThread(innerThread, (t, o) => default);
Expand Down Expand Up @@ -254,6 +261,85 @@ async IAsyncEnumerable<AgentResponseItem<StreamingChatMessageContent>> GetAsyncE
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task RunAsync_RemovesDuplicateTextContentInToolMessage()
{
// Arrange
var innerThread = Mock.Of<AgentThread>();
var agentMock = new Mock<Agent>();
var adapter = new SemanticKernelAIAgent(agentMock.Object, () => innerThread, (e, o) => innerThread, (t, o) => default);

agentMock.Setup(a => a.InvokeAsync(
It.IsAny<List<ChatMessageContent>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentInvokeOptions>(),
It.IsAny<CancellationToken>()))
.Returns((List<ChatMessageContent> msgs, AgentThread thread, AgentInvokeOptions opts, CancellationToken ct) => GetEnumerableWithDuplicateToolMessage(thread, opts));

async IAsyncEnumerable<AgentResponseItem<ChatMessageContent>> GetEnumerableWithDuplicateToolMessage(AgentThread thread, AgentInvokeOptions opts)
{
// Tool message with duplicate text + function result
var toolMessage = new ChatMessageContent(AuthorRole.Tool, "RESULT");
toolMessage.Items.Add(new FunctionResultContent(functionName: "Fn", result: "RESULT"));
await opts.OnIntermediateMessage!.Invoke(toolMessage);

// Final assistant message
var final = new ChatMessageContent(AuthorRole.Assistant, "done");
yield return new AgentResponseItem<ChatMessageContent>(final, thread);
}

var threadWrapper = new SemanticKernelAIAgentThread(innerThread, (t, o) => default);

// Act
var response = await adapter.RunAsync("input", threadWrapper);

// Assert
// Use reflection to inspect Messages collection inside AgentRunResponse
var messages = response.Messages;
var contents = messages.First().Contents;
Assert.Single(contents); // Duplicate text content should have been removed
Assert.IsType<MEAI.FunctionResultContent>(contents.First());
}

[Fact]
public async Task RunAsync_DoesNotRemoveTextContentWhenDifferent()
{
// Arrange
var innerThread = Mock.Of<AgentThread>();
var agentMock = new Mock<Agent>();
var adapter = new SemanticKernelAIAgent(agentMock.Object, () => innerThread, (e, o) => innerThread, (t, o) => default);

agentMock.Setup(a => a.InvokeAsync(
It.IsAny<List<ChatMessageContent>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentInvokeOptions>(),
It.IsAny<CancellationToken>()))
.Returns((List<ChatMessageContent> msgs, AgentThread thread, AgentInvokeOptions opts, CancellationToken ct) => GetEnumerableWithNonDuplicateToolMessage(thread, opts));

async IAsyncEnumerable<AgentResponseItem<ChatMessageContent>> GetEnumerableWithNonDuplicateToolMessage(AgentThread thread, AgentInvokeOptions opts)
{
// Tool message with text + function result differing
var toolMessage = new ChatMessageContent(AuthorRole.Tool, "TEXT");
toolMessage.Items.Add(new FunctionResultContent(functionName: "Fn", result: "DIFFERENT"));
await opts.OnIntermediateMessage!.Invoke(toolMessage);

var final = new ChatMessageContent(AuthorRole.Assistant, "done");
yield return new AgentResponseItem<ChatMessageContent>(final, thread);
}

var threadWrapper = new SemanticKernelAIAgentThread(innerThread, (t, o) => default);

// Act
var response = await adapter.RunAsync("input", threadWrapper);

// Assert
var messages = response.Messages;
var contents = messages.First().Contents;
Assert.Equal(2, contents.Count); // Both contents should remain
Assert.IsType<MEAI.TextContent>(contents.First());
Assert.IsType<MEAI.FunctionResultContent>(contents.Last());
}

[Fact]
public void GetService_WithKernelType_ReturnsKernel()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text.Json;
using Microsoft.SemanticKernel.Agents;
using Microsoft.SemanticKernel.Agents.OpenAI;
using Microsoft.SemanticKernel.ChatCompletion;
using OpenAI.Responses;
using Xunit;

Expand Down Expand Up @@ -37,11 +38,14 @@ public void AsAIAgent_WithNullOpenAIResponseAgent_ThrowsArgumentNullException()
}

[Fact]
public void AsAIAgent_CreatesWorkingThreadFactory()
public void AsAIAgent_CreatesWorkingThreadFactoryStoreTrue()
{
// Arrange
var responseClient = new OpenAIResponseClient("model", "apikey");
var responseAgent = new OpenAIResponseAgent(responseClient);
var responseAgent = new OpenAIResponseAgent(responseClient)
{
StoreEnabled = true
};

// Act
var result = responseAgent.AsAIAgent();
Expand All @@ -54,12 +58,36 @@ public void AsAIAgent_CreatesWorkingThreadFactory()
Assert.IsType<OpenAIResponseAgentThread>(threadAdapter.InnerThread);
}

[Fact]
public void AsAIAgent_CreatesWorkingThreadFactoryStoreFalse()
{
// Arrange
var responseClient = new OpenAIResponseClient("model", "apikey");
var responseAgent = new OpenAIResponseAgent(responseClient)
{
StoreEnabled = false
};

// Act
var result = responseAgent.AsAIAgent();
var thread = result.GetNewThread();

// Assert
Assert.NotNull(thread);
Assert.IsType<SemanticKernelAIAgentThread>(thread);
var threadAdapter = (SemanticKernelAIAgentThread)thread;
Assert.IsType<ChatHistoryAgentThread>(threadAdapter.InnerThread);
}

[Fact]
public void AsAIAgent_ThreadDeserializationFactory_WithNullAgentId_CreatesNewThread()
{
// Arrange
var responseClient = new OpenAIResponseClient("model", "apikey");
var responseAgent = new OpenAIResponseAgent(responseClient);
var responseAgent = new OpenAIResponseAgent(responseClient)
{
StoreEnabled = true
};
var jsonElement = JsonSerializer.SerializeToElement((string?)null);

// Act
Expand All @@ -78,7 +106,10 @@ public void AsAIAgent_ThreadDeserializationFactory_WithValidAgentId_CreatesThrea
{
// Arrange
var responseClient = new OpenAIResponseClient("model", "apikey");
var responseAgent = new OpenAIResponseAgent(responseClient);
var responseAgent = new OpenAIResponseAgent(responseClient)
{
StoreEnabled = true
};
var threadId = "test-agent-id";
var jsonElement = JsonSerializer.SerializeToElement(threadId);

Expand All @@ -99,7 +130,10 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId()
{
// Arrange
var responseClient = new OpenAIResponseClient("model", "apikey");
var responseAgent = new OpenAIResponseAgent(responseClient);
var responseAgent = new OpenAIResponseAgent(responseClient)
{
StoreEnabled = true
};
var expectedThreadId = "test-thread-id";
var responseThread = new OpenAIResponseAgentThread(responseClient, expectedThreadId);
var jsonElement = JsonSerializer.SerializeToElement(expectedThreadId);
Expand All @@ -114,4 +148,77 @@ public void AsAIAgent_ThreadSerializer_SerializesThreadId()
Assert.Equal(JsonValueKind.String, serializedElement.ValueKind);
Assert.Equal(expectedThreadId, serializedElement.GetString());
}

[Fact]
public void AsAIAgent_ThreadDeserializationFactory_WithNullJson_CreatesThreadWithEmptyChatHistory()
{
var responseClient = new OpenAIResponseClient("model", "apikey");
var responseAgent = new OpenAIResponseAgent(responseClient);
var jsonElement = JsonSerializer.SerializeToElement((string?)null);

// Act
var result = responseAgent.AsAIAgent();
var thread = result.DeserializeThread(jsonElement);

// Assert
Assert.NotNull(thread);
Assert.IsType<SemanticKernelAIAgentThread>(thread);
var threadAdapter = (SemanticKernelAIAgentThread)thread;
var chatHistoryAgentThread = Assert.IsType<ChatHistoryAgentThread>(threadAdapter.InnerThread);
Assert.Empty(chatHistoryAgentThread.ChatHistory);
}

[Fact]
public void AsAIAgent_ThreadDeserializationFactory_WithChatHistory_CreatesThreadWithChatHistory()
{
var responseClient = new OpenAIResponseClient("model", "apikey");
var responseAgent = new OpenAIResponseAgent(responseClient);
var expectedChatHistory = new ChatHistory("mock message", AuthorRole.User);
var jsonElement = JsonSerializer.SerializeToElement(expectedChatHistory);

// Act
var result = responseAgent.AsAIAgent();
var thread = result.DeserializeThread(jsonElement);

// Assert
Assert.NotNull(thread);
Assert.IsType<SemanticKernelAIAgentThread>(thread);
var threadAdapter = (SemanticKernelAIAgentThread)thread;
var chatHistoryAgentThread = Assert.IsType<ChatHistoryAgentThread>(threadAdapter.InnerThread);
Assert.Single(chatHistoryAgentThread.ChatHistory);
var firstMessage = chatHistoryAgentThread.ChatHistory[0];
Assert.Equal(AuthorRole.User, firstMessage.Role);
Assert.Equal("mock message", firstMessage.Content);
}

[Fact]
public void AsAIAgent_ThreadSerializer_SerializesChatHistory()
{
// Arrange
var responseClient = new OpenAIResponseClient("model", "apikey");
var responseAgent = new OpenAIResponseAgent(responseClient);
var expectedChatHistory = new ChatHistory("mock message", AuthorRole.User);
var jsonElement = JsonSerializer.SerializeToElement(expectedChatHistory);

var result = responseAgent.AsAIAgent();
var thread = result.DeserializeThread(jsonElement);

// Act
var serializedElement = thread.Serialize();

// Assert
Assert.Equal(JsonValueKind.Array, serializedElement.ValueKind);
Assert.Equal(1, serializedElement.GetArrayLength());

var firstMessage = serializedElement[0];
Assert.True(firstMessage.TryGetProperty("Role", out var roleProp));
Assert.Equal("user", roleProp.GetProperty("Label").GetString());

Assert.True(firstMessage.TryGetProperty("Items", out var itemsProp));
Assert.Equal(1, itemsProp.GetArrayLength());

var firstItem = itemsProp[0];
Assert.Equal("TextContent", firstItem.GetProperty("$type").GetString());
Assert.Equal("mock message", firstItem.GetProperty("Text").GetString());
}
}
Loading
Loading