diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000000..29aae92645
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Documentation
+ url: https://aka.ms/agent-framework
+ about: Check out the official documentation for guides and API reference.
+ - name: Discussions
+ url: https://github.com/microsoft/agent-framework/discussions
+ about: Ask questions about Agent Framework.
diff --git a/.github/ISSUE_TEMPLATE/dotnet-issue.yml b/.github/ISSUE_TEMPLATE/dotnet-issue.yml
new file mode 100644
index 0000000000..3e02fd9e60
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/dotnet-issue.yml
@@ -0,0 +1,70 @@
+name: .NET Bug Report
+description: Report a bug in the Agent Framework .NET SDK
+title: ".NET: [Bug]: "
+labels: ["bug", ".NET"]
+type: bug
+body:
+ - type: textarea
+ id: description
+ attributes:
+ label: Description
+ description: Please provide a clear and detailed description of the bug.
+ placeholder: |
+ - What happened?
+ - What did you expect to happen?
+ - Steps to reproduce the issue
+ validations:
+ required: true
+
+ - type: textarea
+ id: code-sample
+ attributes:
+ label: Code Sample
+ description: If applicable, provide a minimal code sample that demonstrates the issue.
+ placeholder: |
+ ```csharp
+ // Your code here
+ ```
+ render: markdown
+ validations:
+ required: false
+
+ - type: textarea
+ id: error-messages
+ attributes:
+ label: Error Messages / Stack Traces
+ description: Include any error messages or stack traces you received.
+ placeholder: |
+ ```
+ Paste error messages or stack traces here
+ ```
+ render: markdown
+ validations:
+ required: false
+
+ - type: input
+ id: dotnet-packages
+ attributes:
+ label: Package Versions
+ description: List the Microsoft.Agents.* packages and versions you are using
+ placeholder: "e.g., Microsoft.Agents.AI.Abstractions: 1.0.0, Microsoft.Agents.AI.OpenAI: 1.0.0"
+ validations:
+ required: true
+
+ - type: input
+ id: dotnet-version
+ attributes:
+ label: .NET Version
+ description: What version of .NET are you using?
+ placeholder: "e.g., .NET 8.0"
+ validations:
+ required: false
+
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional Context
+ description: Add any other context or screenshots that might be helpful.
+ placeholder: "Any additional information..."
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
new file mode 100644
index 0000000000..1dc13189e7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -0,0 +1,51 @@
+name: Feature Request
+description: Request a new feature for Microsoft Agent Framework
+title: "[Feature]: "
+type: feature
+body:
+
+ - type: textarea
+ id: description
+ attributes:
+ label: Description
+ description: Please describe the feature you'd like and why it would be useful.
+ placeholder: |
+ Describe the feature you're requesting:
+ - What problem does it solve?
+ - What would the expected behavior be?
+ - Are there any alternatives you've considered?
+ validations:
+ required: true
+
+ - type: textarea
+ id: code-sample
+ attributes:
+ label: Code Sample
+ description: If applicable, provide a code sample showing how you'd like to use this feature.
+ placeholder: |
+ ```python
+ # Your code here
+ ```
+
+ or
+
+ ```csharp
+ // Your code here
+ ```
+ render: markdown
+ validations:
+ required: false
+
+ - type: dropdown
+ id: language
+ attributes:
+ label: Language/SDK
+ description: Which language/SDK does this feature apply to?
+ options:
+ - Both
+ - .NET
+ - Python
+ - Other / Not Applicable
+ default: 0
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/python-issue.yml b/.github/ISSUE_TEMPLATE/python-issue.yml
new file mode 100644
index 0000000000..3a506c66fe
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/python-issue.yml
@@ -0,0 +1,70 @@
+name: Python Bug Report
+description: Report a bug in the Agent Framework Python SDK
+title: "Python: [Bug]: "
+labels: ["bug", "Python"]
+type: bug
+body:
+ - type: textarea
+ id: description
+ attributes:
+ label: Description
+ description: Please provide a clear and detailed description of the bug.
+ placeholder: |
+ - What happened?
+ - What did you expect to happen?
+ - Steps to reproduce the issue
+ validations:
+ required: true
+
+ - type: textarea
+ id: code-sample
+ attributes:
+ label: Code Sample
+ description: If applicable, provide a minimal code sample that demonstrates the issue.
+ placeholder: |
+ ```python
+ # Your code here
+ ```
+ render: markdown
+ validations:
+ required: false
+
+ - type: textarea
+ id: error-messages
+ attributes:
+ label: Error Messages / Stack Traces
+ description: Include any error messages or stack traces you received.
+ placeholder: |
+ ```
+ Paste error messages or stack traces here
+ ```
+ render: markdown
+ validations:
+ required: false
+
+ - type: input
+ id: python-packages
+ attributes:
+ label: Package Versions
+ description: List the agent-framework-* packages and versions you are using
+ placeholder: "e.g., agent-framework-core: 1.0.0, agent-framework-azure-ai: 1.0.0"
+ validations:
+ required: true
+
+ - type: input
+ id: python-version
+ attributes:
+ label: Python Version
+ description: What version of Python are you using?
+ placeholder: "e.g., Python 3.11"
+ validations:
+ required: false
+
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional Context
+ description: Add any other context or screenshots that might be helpful.
+ placeholder: "Any additional information..."
+ validations:
+ required: false
diff --git a/.github/workflows/label-issues.yml b/.github/workflows/label-issues.yml
index 231ee6833d..111c63ef13 100644
--- a/.github/workflows/label-issues.yml
+++ b/.github/workflows/label-issues.yml
@@ -45,19 +45,58 @@ jobs:
labels.push("triage")
}
- // Check if the body or the title contains the word 'python' (case-insensitive)
- if ((body != null && body.match(/python/i)) || (title != null && title.match(/python/i))) {
- // Add the 'python' label to the array
- labels.push("python")
+ // Helper function to extract field value from issue form body
+ // Issue forms format fields as: ### Field Name\n\nValue
+ function getFormFieldValue(body, fieldName) {
+ if (!body) return null
+ const regex = new RegExp(`###\\s*${fieldName}\\s*\\n\\n([^\\n#]+)`, 'i')
+ const match = body.match(regex)
+ return match ? match[1].trim() : null
}
- // Check if the body or the title contains the words 'dotnet', '.net', 'c#' or 'csharp' (case-insensitive)
- if ((body != null && body.match(/.net/i)) || (title != null && title.match(/.net/i)) ||
- (body != null && body.match(/dotnet/i)) || (title != null && title.match(/dotnet/i)) ||
- (body != null && body.match(/C#/i)) || (title != null && title.match(/C#/i)) ||
- (body != null && body.match(/csharp/i)) || (title != null && title.match(/csharp/i))) {
- // Add the '.NET' label to the array
- labels.push(".NET")
+ // Check for language from issue form dropdown first
+ const languageField = getFormFieldValue(body, 'Language')
+ let languageLabelAdded = false
+
+ if (languageField) {
+ if (languageField === 'Python') {
+ labels.push("python")
+ languageLabelAdded = true
+ } else if (languageField === '.NET') {
+ labels.push(".NET")
+ languageLabelAdded = true
+ }
+ // 'None / Not Applicable' - don't add any language label
+ }
+
+ // Fallback: Check if the body or the title contains the word 'python' (case-insensitive)
+ // Only if language wasn't already determined from the form field
+ if (!languageLabelAdded) {
+ if ((body != null && body.match(/python/i)) || (title != null && title.match(/python/i))) {
+ // Add the 'python' label to the array
+ labels.push("python")
+ }
+
+ // Check if the body or the title contains the words 'dotnet', '.net', 'c#' or 'csharp' (case-insensitive)
+ if ((body != null && body.match(/\.net/i)) || (title != null && title.match(/\.net/i)) ||
+ (body != null && body.match(/dotnet/i)) || (title != null && title.match(/dotnet/i)) ||
+ (body != null && body.match(/C#/i)) || (title != null && title.match(/C#/i)) ||
+ (body != null && body.match(/csharp/i)) || (title != null && title.match(/csharp/i))) {
+ // Add the '.NET' label to the array
+ labels.push(".NET")
+ }
+ }
+
+ // Check for issue type from issue form dropdown
+ const issueTypeField = getFormFieldValue(body, 'Type of Issue')
+ if (issueTypeField) {
+ if (issueTypeField === 'Bug') {
+ labels.push("bug")
+ } else if (issueTypeField === 'Feature Request') {
+ labels.push("enhancement")
+ } else if (issueTypeField === 'Question') {
+ labels.push("question")
+ }
}
// Add the labels to the issue (only if there are labels to add)
diff --git a/docs/decisions/0001-agent-run-response.md b/docs/decisions/0001-agent-run-response.md
index b60878adff..9f13af787c 100644
--- a/docs/decisions/0001-agent-run-response.md
+++ b/docs/decisions/0001-agent-run-response.md
@@ -64,7 +64,7 @@ Approaches observed from the compared SDKs:
| AutoGen | **Approach 1** Separates messages into Agent-Agent (maps to Primary) and Internal (maps to Secondary) and these are returned as separate properties on the agent response object. See [types of messages](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/messages.html#types-of-messages) and [Response](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.Response) | **Approach 2** Returns a stream of internal events and the last item is a Response object. See [ChatAgent.on_messages_stream](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.ChatAgent.on_messages_stream) |
| OpenAI Agent SDK | **Approach 1** Separates new_items (Primary+Secondary) from final output (Primary) as separate properties on the [RunResult](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L39) | **Approach 1** Similar to non-streaming, has a way of streaming updates via a method on the response object which includes all data, and then a separate final output property on the response object which is populated only when the run is complete. See [RunResultStreaming](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L136) |
| Google ADK | **Approach 2** [Emits events](https://google.github.io/adk-docs/runtime/#step-by-step-breakdown) with [FinalResponse](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L232) true (Primary) / false (Secondary) and callers have to filter out those with false to get just the final response message | **Approach 2** Similar to non-streaming except [events](https://google.github.io/adk-docs/runtime/#streaming-vs-non-streaming-output-partialtrue) are emitted with [Partial](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L133) true to indicate that they are streaming messages. A final non partial event is also emitted. |
-| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent_result.AgentResult) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent.Agent.stream_async) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
+| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.stream_async) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
| LangGraph | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
| Agno | **Combination of various approaches** Returns a [RunResponse](https://docs.agno.com/reference/agents/run-response) object with text content, messages (essentially chat history including inputs and instructions), reasoning and thinking text properties. Secondary events could potentially be extracted from messages. | **Approach 2** Returns [RunResponseEvent](https://docs.agno.com/reference/agents/run-response#runresponseevent-types-and-attributes) objects including tool call, memory update, etc, information, where the [RunResponseCompletedEvent](https://docs.agno.com/reference/agents/run-response#runresponsecompletedevent) has similar properties to RunResponse|
| A2A | **Approach 3** Returns a [Task or Message](https://a2aproject.github.io/A2A/latest/specification/#71-messagesend) where the message is the final result (Primary) and task is a reference to a long running process. | **Approach 2** Returns a [stream](https://a2aproject.github.io/A2A/latest/specification/#72-messagestream) that contains task updates (Secondary) and a final message (Primary) |
@@ -495,8 +495,8 @@ We need to decide what AIContent types, each agent response type will be mapped
| SDK | Structured Outputs support |
|-|-|
| AutoGen | **Approach 1** Supports [configuring an agent](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/agents.html#structured-output) at agent creation. |
-| Google ADK | **Approach 1** Both [input and output shemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support |
-| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent.Agent.structured_output) |
+| Google ADK | **Approach 1** Both [input and output schemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support |
+| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.structured_output) |
| LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response |
| Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/examples/getting-started/structured-output) at agent construction time |
| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time |
@@ -508,7 +508,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|-|-|
| AutoGen | Supports a [stop reason](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.TaskResult.stop_reason) which is a freeform text string |
| Google ADK | [No equivalent present](https://github.com/google/adk-python/blob/main/src/google/adk/events/event.py) |
-| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/latest/api-reference/types/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent_result.AgentResult) class with options that are tied closely to LLM operations. |
+| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/latest/documentation/docs/api-reference/python/types/event_loop/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) class with options that are tied closely to LLM operations. |
| LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
| Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) |
| A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). |
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 9202b36f2a..5ee419114b 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -11,7 +11,7 @@
-
+
@@ -26,7 +26,7 @@
-
+
@@ -100,7 +100,7 @@
-
+
diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props
index 3fc7c3974b..4982c91ce5 100644
--- a/dotnet/nuget/nuget-package.props
+++ b/dotnet/nuget/nuget-package.props
@@ -2,9 +2,9 @@
1.0.0
- $(VersionPrefix)-$(VersionSuffix).251219.1
- $(VersionPrefix)-preview.251219.1
- 1.0.0-preview.251219.1
+ $(VersionPrefix)-$(VersionSuffix).260108.1
+ $(VersionPrefix)-preview.260108.1
+ 1.0.0-preview.260108.1
Debug;Release;Publish
true
diff --git a/dotnet/samples/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs b/dotnet/samples/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs
index e642b64337..a6d3e9db55 100644
--- a/dotnet/samples/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs
+++ b/dotnet/samples/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs
@@ -96,16 +96,15 @@ public async Task CreateAsync(
// Create a new agent thread
AgentThread thread = agentProxy.GetNewThread();
- AgentThreadMetadata metadata = thread.GetService()
- ?? throw new InvalidOperationException("Failed to get AgentThreadMetadata from new thread.");
+ string agentSessionId = thread.GetService().ToString();
- this._logger.LogInformation("Creating new agent session: {ConversationId}", metadata.ConversationId);
+ this._logger.LogInformation("Creating new agent session: {AgentSessionId}", agentSessionId);
// Run the agent in the background (fire-and-forget)
DurableAgentRunOptions options = new() { IsFireAndForget = true };
await agentProxy.RunAsync(prompt, thread, options, cancellationToken);
- this._logger.LogInformation("Agent run started for session: {ConversationId}", metadata.ConversationId);
+ this._logger.LogInformation("Agent run started for session: {AgentSessionId}", agentSessionId);
// Check Accept header to determine response format
// text/plain = raw text output (ideal for terminals)
@@ -114,7 +113,7 @@ public async Task CreateAsync(
bool useSseFormat = acceptHeader?.Contains("text/plain", StringComparison.OrdinalIgnoreCase) != true;
return await this.StreamToClientAsync(
- conversationId: metadata.ConversationId!, cursor: null, useSseFormat, request.HttpContext, cancellationToken);
+ conversationId: agentSessionId, cursor: null, useSseFormat, request.HttpContext, cancellationToken);
}
///
diff --git a/dotnet/samples/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs b/dotnet/samples/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs
index b0a95f49f6..21f944338a 100644
--- a/dotnet/samples/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs
+++ b/dotnet/samples/AzureFunctions/08_ReliableStreaming/RedisStreamResponseHandler.cs
@@ -65,11 +65,10 @@ public async ValueTask OnStreamingResponseUpdateAsync(
"DurableAgentContext.Current is not set. This handler must be used within a durable agent context.");
}
- // Get conversation ID from the current thread context, which is only available in the context of
+ // Get session ID from the current thread context, which is only available in the context of
// a durable agent execution.
- string conversationId = context.CurrentThread.GetService()?.ConversationId
- ?? throw new InvalidOperationException("Unable to determine conversation ID from the current thread.");
- string streamKey = GetStreamKey(conversationId);
+ string agentSessionId = context.CurrentThread.GetService().ToString();
+ string streamKey = GetStreamKey(agentSessionId);
IDatabase db = this._redis.GetDatabase();
int sequenceNumber = 0;
diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs
index a4e588f347..6beef64405 100644
--- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs
@@ -44,11 +44,19 @@ protected override async Task RunCoreAsync(IEnumerable responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
// Notify the thread of the input and output messages.
- await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken);
+ var invokedContext = new ChatMessageStore.InvokedContext(messages, storeMessages)
+ {
+ ResponseMessages = responseMessages
+ };
+ await typedThread.MessageStore.InvokedAsync(invokedContext, cancellationToken);
return new AgentRunResponse
{
@@ -68,11 +76,19 @@ protected override async IAsyncEnumerable RunCoreStreami
throw new ArgumentException($"The provided thread is not of type {nameof(CustomAgentThread)}.", nameof(thread));
}
+ // Get existing messages from the store
+ var invokingContext = new ChatMessageStore.InvokingContext(messages);
+ var storeMessages = await typedThread.MessageStore.InvokingAsync(invokingContext, cancellationToken);
+
// Clone the input messages and turn them into response messages with upper case text.
List responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
// Notify the thread of the input and output messages.
- await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken);
+ var invokedContext = new ChatMessageStore.InvokedContext(messages, storeMessages)
+ {
+ ResponseMessages = responseMessages
+ };
+ await typedThread.MessageStore.InvokedAsync(invokedContext, cancellationToken);
foreach (var message in responseMessages)
{
diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs
index 42015d87cd..9207a08182 100644
--- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs
@@ -62,7 +62,12 @@
.CreateAIAgent(new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available." },
- AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
+ AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions),
+ // Since we are using ChatCompletion which stores chat history locally, we can also add a message removal policy
+ // that removes messages produced by the TextSearchProvider before they are added to the chat history, so that
+ // we don't bloat chat history with all the search result messages.
+ ChatMessageStoreFactory = ctx => new InMemoryChatMessageStore(ctx.SerializedState, ctx.JsonSerializerOptions)
+ .WithAIContextProviderMessageRemoval(),
});
AgentThread thread = agent.GetNewThread();
diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs
index e9794e871a..280c84dc0d 100644
--- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs
+++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs
@@ -89,24 +89,7 @@ public VectorChatMessageStore(VectorStore vectorStore, JsonElement serializedSto
public string? ThreadDbKey { get; private set; }
- public override async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default)
- {
- this.ThreadDbKey ??= Guid.NewGuid().ToString("N");
-
- var collection = this._vectorStore.GetCollection("ChatHistory");
- await collection.EnsureCollectionExistsAsync(cancellationToken);
-
- await collection.UpsertAsync(messages.Select(x => new ChatHistoryItem()
- {
- Key = this.ThreadDbKey + x.MessageId,
- Timestamp = DateTimeOffset.UtcNow,
- ThreadId = this.ThreadDbKey,
- SerializedMessage = JsonSerializer.Serialize(x),
- MessageText = x.Text
- }), cancellationToken);
- }
-
- public override async Task> GetMessagesAsync(CancellationToken cancellationToken = default)
+ public override async ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var collection = this._vectorStore.GetCollection("ChatHistory");
await collection.EnsureCollectionExistsAsync(cancellationToken);
@@ -124,6 +107,33 @@ public override async Task> GetMessagesAsync(Cancellati
return messages;
}
+ public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
+ {
+ // Don't store messages if the request failed.
+ if (context.InvokeException is not null)
+ {
+ return;
+ }
+
+ this.ThreadDbKey ??= Guid.NewGuid().ToString("N");
+
+ var collection = this._vectorStore.GetCollection("ChatHistory");
+ await collection.EnsureCollectionExistsAsync(cancellationToken);
+
+ // Add both request and response messages to the store
+ // Optionally messages produced by the AIContextProvider can also be persisted (not shown).
+ var allNewMessages = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []);
+
+ await collection.UpsertAsync(allNewMessages.Select(x => new ChatHistoryItem()
+ {
+ Key = this.ThreadDbKey + x.MessageId,
+ Timestamp = DateTimeOffset.UtcNow,
+ ThreadId = this.ThreadDbKey,
+ SerializedMessage = JsonSerializer.Serialize(x),
+ MessageText = x.Text
+ }), cancellationToken);
+ }
+
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) =>
// We have to serialize the thread id, so that on deserialization we can retrieve the messages using the same thread id.
JsonSerializer.SerializeToElement(this.ThreadDbKey);
diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj b/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj
index d2c0ea70f8..1244b81542 100644
--- a/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj
+++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs
index 9cbea8b73a..4dffdf92bd 100644
--- a/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs
+++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs
@@ -9,7 +9,7 @@
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
-using OpenAI;
+using OpenAI.Responses;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
@@ -25,7 +25,7 @@
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
- .GetOpenAIResponseClient(deploymentName)
+ .GetResponsesClient(deploymentName)
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgent",
diff --git a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj
index 54791c1992..03ffaf1824 100644
--- a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj
+++ b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj
@@ -38,8 +38,8 @@
-
-
+
+
diff --git a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs
index d8be12c5b5..b197ffeefc 100644
--- a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs
+++ b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs
@@ -8,9 +8,8 @@
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
-using Microsoft.Agents.AI.Data;
using Microsoft.Extensions.AI;
-using OpenAI;
+using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
@@ -28,7 +27,10 @@
.GetChatClient(deploymentName)
.CreateAIAgent(new ChatClientAgentOptions
{
- Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
+ ChatOptions = new ChatOptions
+ {
+ Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
+ },
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
diff --git a/dotnet/samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj b/dotnet/samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj
index ad23b11b17..a434e07d33 100644
--- a/dotnet/samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj
+++ b/dotnet/samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj
@@ -38,7 +38,7 @@
-
+
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
index cf88a89177..96a8856dea 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
@@ -24,7 +24,7 @@ namespace Microsoft.Agents.AI.A2A;
/// Support for tasks will be added later as part of the long-running
/// executions work.
///
-internal sealed class A2AAgent : AIAgent
+public sealed class A2AAgent : AIAgent
{
private readonly A2AClient _a2aClient;
private readonly string? _id;
@@ -84,9 +84,13 @@ protected override async Task RunCoreAsync(IEnumerable RunCoreStreami
// a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
}
- var a2aMessage = CreateA2AMessage(typedThread, messages);
+ MessageSendParams sendParams = new()
+ {
+ Message = CreateA2AMessage(typedThread, messages),
+ Metadata = options?.AdditionalProperties?.ToA2AMetadata()
+ };
- a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
+ a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
@@ -198,10 +206,10 @@ protected override async IAsyncEnumerable RunCoreStreami
protected override string? IdCore => this._id;
///
- public override string? Name => this._name ?? base.Name;
+ public override string? Name => this._name;
///
- public override string? Description => this._description ?? base.Description;
+ public override string? Description => this._description;
private A2AAgentThread GetA2AThread(AgentThread? thread, AgentRunOptions? options)
{
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AMetadataExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AMetadataExtensions.cs
index c0dedbd541..3c81c6abe8 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AMetadataExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AMetadataExtensions.cs
@@ -14,6 +14,9 @@ internal static class A2AMetadataExtensions
///
/// Converts a dictionary of metadata to an .
///
+ ///
+ /// This method can be replaced by the one from A2A SDK once it is public.
+ ///
/// The metadata dictionary to convert.
/// The converted , or null if the input is null or empty.
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary? metadata)
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs
new file mode 100644
index 0000000000..a3340d2ca8
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/AdditionalPropertiesDictionaryExtensions.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using Microsoft.Agents.AI;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Extension methods for AdditionalPropertiesDictionary.
+///
+internal static class AdditionalPropertiesDictionaryExtensions
+{
+ ///
+ /// Converts an to a dictionary of values suitable for A2A metadata.
+ ///
+ ///
+ /// This method can be replaced by the one from A2A SDK once it is available.
+ ///
+ /// The additional properties dictionary to convert, or null.
+ /// A dictionary of JSON elements representing the metadata, or null if the input is null or empty.
+ internal static Dictionary? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
+ {
+ if (additionalProperties is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ var metadata = new Dictionary();
+
+ foreach (var kvp in additionalProperties)
+ {
+ if (kvp.Value is JsonElement)
+ {
+ metadata[kvp.Key] = (JsonElement)kvp.Value!;
+ continue;
+ }
+
+ metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
+ }
+
+ return metadata;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponse.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponse.cs
index 001cfd9469..7828b5c62d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponse.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunResponse.cs
@@ -291,6 +291,15 @@ public AgentRunResponseUpdate[] ToAgentRunResponseUpdates()
return updates;
}
+ ///
+ /// Deserializes the response text into the given type.
+ ///
+ /// The output type to deserialize into.
+ /// The result as the requested type.
+ /// The result is not parsable into the requested type.
+ public T Deserialize() =>
+ this.Deserialize(AgentAbstractionsJsonUtilities.DefaultOptions);
+
///
/// Deserializes the response text into the given type using the specified serializer options.
///
@@ -311,6 +320,15 @@ public T Deserialize(JsonSerializerOptions serializerOptions)
};
}
+ ///
+ /// Tries to deserialize response text into the given type.
+ ///
+ /// The output type to deserialize into.
+ /// The parsed structured output.
+ /// if parsing was successful; otherwise, .
+ public bool TryDeserialize([NotNullWhen(true)] out T? structuredOutput) =>
+ this.TryDeserialize(AgentAbstractionsJsonUtilities.DefaultOptions, out structuredOutput);
+
///
/// Tries to deserialize response text into the given type using the specified serializer options.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs
index 4794457f41..0a3301d05f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThread.cs
@@ -68,7 +68,7 @@ public virtual JsonElement Serialize(JsonSerializerOptions? jsonSerializerOption
/// is .
///
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the ,
- /// including itself or any services it might be wrapping. For example, to access the for the instance,
+ /// including itself or any services it might be wrapping. For example, to access a if available for the instance,
/// may be used to request it.
///
public virtual object? GetService(Type serviceType, object? serviceKey = null)
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThreadMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThreadMetadata.cs
deleted file mode 100644
index 3a2d506745..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentThreadMetadata.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Diagnostics;
-
-namespace Microsoft.Agents.AI;
-
-///
-/// Provides metadata information about an instance.
-///
-[DebuggerDisplay("ConversationId = {ConversationId}")]
-public class AgentThreadMetadata
-{
- ///
- /// Initializes a new instance of the class.
- ///
- /// The unique identifier for the conversation, if available.
- public AgentThreadMetadata(string? conversationId)
- {
- this.ConversationId = conversationId;
- }
-
- ///
- /// Gets the unique identifier for the conversation, if available.
- ///
- ///
- /// The meaning of this ID may vary depending on the agent implementation.
- ///
- public string? ConversationId { get; }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs
index 9f89031464..d28cd191b7 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs
@@ -32,8 +32,9 @@ namespace Microsoft.Agents.AI;
public abstract class ChatMessageStore
{
///
- /// Asynchronously retrieves all messages from the store that should be provided as context for the next agent invocation.
+ /// Called at the start of agent invocation to retrieve all messages from the store that should be provided as context for the next agent invocation.
///
+ /// Contains the request context including the caller provided messages that will be used by the agent for this invocation.
/// The to monitor for cancellation requests. The default is .
///
/// A task that represents the asynchronous operation. The task result contains a collection of
@@ -59,20 +60,19 @@ public abstract class ChatMessageStore
/// and context management.
///
///
- public abstract Task> GetMessagesAsync(CancellationToken cancellationToken = default);
+ public abstract ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default);
///
- /// Asynchronously adds new messages to the store.
+ /// Called at the end of the agent invocation to add new messages to the store.
///
- /// The collection of chat messages to add to the store.
+ /// Contains the invocation context including request messages, response messages, and any exception that occurred.
/// The to monitor for cancellation requests. The default is .
/// A task that represents the asynchronous add operation.
- /// is .
///
///
/// Messages should be added in the order they were generated to maintain proper chronological sequence.
/// The store is responsible for preserving message ordering and ensuring that subsequent calls to
- /// return messages in the correct chronological order.
+ /// return messages in the correct chronological order.
///
///
/// Implementations may perform additional processing during message addition, such as:
@@ -83,8 +83,12 @@ public abstract class ChatMessageStore
/// - Updating indices or search capabilities
///
///
+ ///
+ /// This method is called regardless of whether the invocation succeeded or failed.
+ /// To check if the invocation was successful, inspect the property.
+ ///
///
- public abstract Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default);
+ public abstract ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default);
///
/// Serializes the current object's state to a using the specified serialization options.
@@ -121,4 +125,100 @@ public abstract class ChatMessageStore
///
public TService? GetService(object? serviceKey = null)
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
+
+ ///
+ /// Contains the context information provided to .
+ ///
+ ///
+ /// This class provides context about the invocation before the messages are retrieved from the store,
+ /// including the new messages that will be used. Stores can use this information to determine what
+ /// messages should be retrieved for the invocation.
+ ///
+ public sealed class InvokingContext
+ {
+ ///
+ /// Initializes a new instance of the class with the specified request messages.
+ ///
+ /// The new messages to be used by the agent for this invocation.
+ /// is .
+ public InvokingContext(IEnumerable requestMessages)
+ {
+ this.RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages));
+ }
+
+ ///
+ /// Gets the caller provided messages that will be used by the agent for this invocation.
+ ///
+ ///
+ /// A collection of instances representing new messages that were provided by the caller.
+ ///
+ public IEnumerable RequestMessages { get; }
+ }
+
+ ///
+ /// Contains the context information provided to .
+ ///
+ ///
+ /// This class provides context about a completed agent invocation, including both the
+ /// request messages that were used and the response messages that were generated. It also indicates
+ /// whether the invocation succeeded or failed.
+ ///
+ public sealed class InvokedContext
+ {
+ ///
+ /// Initializes a new instance of the class with the specified request messages.
+ ///
+ /// The caller provided messages that were used by the agent for this invocation.
+ /// The messages retrieved from the for this invocation.
+ /// is .
+ public InvokedContext(IEnumerable requestMessages, IEnumerable chatMessageStoreMessages)
+ {
+ this.RequestMessages = Throw.IfNull(requestMessages);
+ this.ChatMessageStoreMessages = chatMessageStoreMessages;
+ }
+
+ ///
+ /// Gets the caller provided messages that were used by the agent for this invocation.
+ ///
+ ///
+ /// A collection of instances representing new messages that were provided by the caller.
+ /// This does not include any supplied messages.
+ ///
+ public IEnumerable RequestMessages { get; }
+
+ ///
+ /// Gets the messages retrieved from the for this invocation, if any.
+ ///
+ ///
+ /// A collection of instances that were retrieved from the ,
+ /// and were used by the agent as part of the invocation.
+ ///
+ public IEnumerable ChatMessageStoreMessages { get; }
+
+ ///
+ /// Gets or sets the messages provided by the for this invocation, if any.
+ ///
+ ///
+ /// A collection of instances that were provided by the ,
+ /// and were used by the agent as part of the invocation.
+ ///
+ public IEnumerable? AIContextProviderMessages { get; set; }
+
+ ///
+ /// Gets the collection of response messages generated during this invocation if the invocation succeeded.
+ ///
+ ///
+ /// A collection of instances representing the response,
+ /// or if the invocation failed or did not produce response messages.
+ ///
+ public IEnumerable? ResponseMessages { get; set; }
+
+ ///
+ /// Gets the that was thrown during the invocation, if the invocation failed.
+ ///
+ ///
+ /// The exception that caused the invocation to fail, or if the invocation succeeded.
+ ///
+ public Exception? InvokeException { get; set; }
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreExtensions.cs
new file mode 100644
index 0000000000..a205fc1d9e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreExtensions.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Contains extension methods for the class.
+///
+public static class ChatMessageStoreExtensions
+{
+ ///
+ /// Adds message filtering to an existing store, so that messages passed to the store and messages produced by the store
+ /// can be filtered, updated or replaced.
+ ///
+ /// The store to add the message filter to.
+ /// An optional filter function to apply to messages produced by the store. If null, no filter is applied at this
+ /// stage.
+ /// An optional filter function to apply to the invoked context messages before they are passed to the store. If null, no
+ /// filter is applied at this stage.
+ /// The with filtering applied.
+ public static ChatMessageStore WithMessageFilters(
+ this ChatMessageStore store,
+ Func, IEnumerable>? invokingMessagesFilter = null,
+ Func? invokedMessagesFilter = null)
+ {
+ return new ChatMessageStoreMessageFilter(
+ innerChatMessageStore: store,
+ invokingMessagesFilter: invokingMessagesFilter,
+ invokedMessagesFilter: invokedMessagesFilter);
+ }
+
+ ///
+ /// Decorates the provided chat message store so that it does not store messages produced by any .
+ ///
+ /// The store to add the message filter to.
+ /// A new instance that filters out messages so they do not get stored.
+ public static ChatMessageStore WithAIContextProviderMessageRemoval(this ChatMessageStore store)
+ {
+ return new ChatMessageStoreMessageFilter(
+ innerChatMessageStore: store,
+ invokedMessagesFilter: (ctx) =>
+ {
+ ctx.AIContextProviderMessages = null;
+ return ctx;
+ });
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreMessageFilter.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreMessageFilter.cs
new file mode 100644
index 0000000000..e58f233067
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStoreMessageFilter.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A decorator that allows filtering the messages
+/// passed into and out of an inner .
+///
+public sealed class ChatMessageStoreMessageFilter : ChatMessageStore
+{
+ private readonly ChatMessageStore _innerChatMessageStore;
+ private readonly Func, IEnumerable>? _invokingMessagesFilter;
+ private readonly Func? _invokedMessagesFilter;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Use this constructor to customize how messages are filtered before and after invocation by
+ /// providing appropriate filter functions. If no filters are provided, the message store operates without
+ /// additional filtering.
+ /// The underlying chat message store to be wrapped. Cannot be null.
+ /// An optional filter function to apply to messages before they are invoked. If null, no filter is applied at this
+ /// stage.
+ /// An optional filter function to apply to the invocation context after messages have been invoked. If null, no
+ /// filter is applied at this stage.
+ /// Thrown if innerChatMessageStore is null.
+ public ChatMessageStoreMessageFilter(
+ ChatMessageStore innerChatMessageStore,
+ Func, IEnumerable>? invokingMessagesFilter = null,
+ Func? invokedMessagesFilter = null)
+ {
+ this._innerChatMessageStore = Throw.IfNull(innerChatMessageStore);
+
+ if (invokingMessagesFilter == null && invokedMessagesFilter == null)
+ {
+ throw new ArgumentException("At least one filter function, invokingMessagesFilter or invokedMessagesFilter, must be provided.");
+ }
+
+ this._invokingMessagesFilter = invokingMessagesFilter;
+ this._invokedMessagesFilter = invokedMessagesFilter;
+ }
+
+ ///
+ public override async ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ {
+ var messages = await this._innerChatMessageStore.InvokingAsync(context, cancellationToken).ConfigureAwait(false);
+ return this._invokingMessagesFilter != null ? this._invokingMessagesFilter(messages) : messages;
+ }
+
+ ///
+ public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
+ {
+ if (this._invokedMessagesFilter != null)
+ {
+ context = this._invokedMessagesFilter(context);
+ }
+
+ return this._innerChatMessageStore.InvokedAsync(context, cancellationToken);
+ }
+
+ ///
+ public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
+ {
+ return this._innerChatMessageStore.Serialize(jsonSerializerOptions);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs
index 79d303207c..f7f4522f8f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatMessageStore.cs
@@ -134,27 +134,36 @@ public ChatMessage this[int index]
}
///
- public override async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default)
+ public override async ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
- _ = Throw.IfNull(messages);
+ _ = Throw.IfNull(context);
- this._messages.AddRange(messages);
-
- if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
+ if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
{
this._messages = (await this.ChatReducer.ReduceAsync(this._messages, cancellationToken).ConfigureAwait(false)).ToList();
}
+
+ return this._messages;
}
///
- public override async Task> GetMessagesAsync(CancellationToken cancellationToken = default)
+ public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
- if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
+ _ = Throw.IfNull(context);
+
+ if (context.InvokeException is not null)
{
- this._messages = (await this.ChatReducer.ReduceAsync(this._messages, cancellationToken).ConfigureAwait(false)).ToList();
+ return;
}
- return this._messages;
+ // Add request, AI context provider, and response messages to the store
+ var allNewMessages = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []);
+ this._messages.AddRange(allNewMessages);
+
+ if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
+ {
+ this._messages = (await this.ChatReducer.ReduceAsync(this._messages, cancellationToken).ConfigureAwait(false)).ToList();
+ }
}
///
@@ -221,7 +230,7 @@ public enum ChatReducerTriggerEvent
{
///
/// Trigger the reducer when a new message is added.
- /// will only complete when reducer processing is done.
+ /// will only complete when reducer processing is done.
///
AfterMessageAdded,
diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs
index 03334d90f9..5c2c23ff9e 100644
--- a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatMessageStore.cs
@@ -287,7 +287,7 @@ public static CosmosChatMessageStore CreateFromSerializedState(CosmosClient cosm
}
///
- public override async Task> GetMessagesAsync(CancellationToken cancellationToken = default)
+ public override async ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
@@ -347,11 +347,14 @@ public override async Task> GetMessagesAsync(Cancellati
}
///
- public override async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default)
+ public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
- if (messages is null)
+ Throw.IfNull(context);
+
+ if (context.InvokeException is not null)
{
- throw new ArgumentNullException(nameof(messages));
+ // Do not store messages if there was an exception during invocation
+ return;
}
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
@@ -361,7 +364,7 @@ public override async Task AddMessagesAsync(IEnumerable messages, C
}
#pragma warning restore CA1513
- var messageList = messages as IReadOnlyCollection ?? messages.ToList();
+ var messageList = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []).ToList();
if (messageList.Count == 0)
{
return;
@@ -381,7 +384,7 @@ public override async Task AddMessagesAsync(IEnumerable messages, C
///
/// Adds multiple messages using transactional batch operations for atomicity.
///
- private async Task AddMessagesInBatchAsync(IReadOnlyCollection messages, CancellationToken cancellationToken)
+ private async Task AddMessagesInBatchAsync(List messages, CancellationToken cancellationToken)
{
var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
index ccc6aa7181..8f8f64fe5c 100644
--- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
+++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
@@ -6,6 +6,7 @@
- Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679))
- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843))
+- Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067));
## v1.0.0-preview.251204.1
diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs
index 32dea2cb18..98dc8ea4b1 100644
--- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentThread.cs
@@ -55,12 +55,6 @@ internal static DurableAgentThread Deserialize(JsonElement serializedThread, Jso
///
public override object? GetService(Type serviceType, object? serviceKey = null)
{
- // This is a common convention for MAF agents.
- if (serviceType == typeof(AgentThreadMetadata))
- {
- return new AgentThreadMetadata(conversationId: this.SessionId.ToString());
- }
-
if (serviceType == typeof(AgentSessionId))
{
return this.SessionId;
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
index c54af66bb8..499d724b1a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
@@ -43,10 +43,14 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara
{
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var thread = await hostAgent.GetOrCreateThreadAsync(contextId, cancellationToken).ConfigureAwait(false);
+ var options = messageSendParams.Metadata is not { Count: > 0 }
+ ? null
+ : new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
thread: thread,
+ options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveThreadAsync(contextId, thread, cancellationToken).ConfigureAwait(false);
@@ -56,7 +60,8 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
- Parts = parts
+ Parts = parts,
+ Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/A2AMetadataExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/A2AMetadataExtensions.cs
new file mode 100644
index 0000000000..010264bb65
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/A2AMetadataExtensions.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
+
+///
+/// Extension methods for A2A metadata dictionary.
+///
+internal static class A2AMetadataExtensions
+{
+ ///
+ /// Converts a dictionary of metadata to an .
+ ///
+ ///
+ /// This method can be replaced by the one from A2A SDK once it is public.
+ ///
+ /// The metadata dictionary to convert.
+ /// The converted , or null if the input is null or empty.
+ internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary? metadata)
+ {
+ if (metadata is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ var additionalProperties = new AdditionalPropertiesDictionary();
+ foreach (var kvp in metadata)
+ {
+ additionalProperties[kvp.Key] = kvp.Value;
+ }
+ return additionalProperties;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs
new file mode 100644
index 0000000000..d46ef72d1f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using A2A;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
+
+///
+/// Extension methods for AdditionalPropertiesDictionary.
+///
+internal static class AdditionalPropertiesDictionaryExtensions
+{
+ ///
+ /// Converts an to a dictionary of values suitable for A2A metadata.
+ ///
+ ///
+ /// This method can be replaced by the one from A2A SDK once it is available.
+ ///
+ /// The additional properties dictionary to convert, or null.
+ /// A dictionary of JSON elements representing the metadata, or null if the input is null or empty.
+ internal static Dictionary? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
+ {
+ if (additionalProperties is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ var metadata = new Dictionary();
+
+ foreach (var kvp in additionalProperties)
+ {
+ if (kvp.Value is JsonElement)
+ {
+ metadata[kvp.Key] = (JsonElement)kvp.Value!;
+ continue;
+ }
+
+ metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
+ }
+
+ return metadata;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md
index 3e46ceff65..1ee2a25826 100644
--- a/dotnet/src/Microsoft.Agents.AI.Purview/README.md
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md
@@ -50,7 +50,7 @@ TokenCredential browserCredential = new InteractiveBrowserCredential(
IChatClient client = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
- .GetOpenAIResponseClient(deploymentName)
+ .GetResponsesClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.WithPurview(browserCredential, new PurviewSettings("My Sample App"))
@@ -198,7 +198,7 @@ Use the chat middleware when you attach directly to a chat client (e.g. minimal
IChatClient client = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
- .GetOpenAIResponseClient(deploymentName)
+ .GetResponsesClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App"))
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs
index 7af7efd0b9..aec9e8130c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs
@@ -10,4 +10,10 @@ namespace Microsoft.Agents.AI.Workflows;
///
/// Optionally, the representing the error.
///
-public class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e);
+public class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e)
+{
+ ///
+ /// Gets the exception that caused the current operation to fail, if one occurred.
+ ///
+ public Exception? Exception => this.Data as Exception;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs
index 7c0479b85e..4e5ee86070 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs
@@ -18,11 +18,12 @@ internal sealed class WorkflowHostAgent : AIAgent
private readonly string? _id;
private readonly CheckpointManager? _checkpointManager;
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
+ private readonly bool _includeExceptionDetails;
private readonly Task _describeTask;
private readonly ConcurrentDictionary _assignedRunIds = [];
- public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null)
+ public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false)
{
this._workflow = Throw.IfNull(workflow);
@@ -30,6 +31,7 @@ public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = nu
? InProcessExecution.Concurrent
: InProcessExecution.OffThread);
this._checkpointManager = checkpointManager;
+ this._includeExceptionDetails = includeExceptionDetails;
this._id = id;
this.Name = name;
@@ -61,12 +63,12 @@ private async ValueTask ValidateWorkflowAsync()
protocol.ThrowIfNotChatProtocol();
}
- public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager);
+ public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails);
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
- => new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, jsonSerializerOptions);
+ => new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, jsonSerializerOptions);
- private async ValueTask UpdateThreadAsync(IEnumerable messages, AgentThread? thread = null, CancellationToken cancellationToken = default)
+ private ValueTask UpdateThreadAsync(IEnumerable messages, AgentThread? thread = null, CancellationToken cancellationToken = default)
{
thread ??= this.GetNewThread();
@@ -75,8 +77,10 @@ private async ValueTask UpdateThreadAsync(IEnumerable(workflowThread);
}
protected override async
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs
index d48e99bf6e..c217039a35 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs
@@ -21,6 +21,8 @@ public static class WorkflowHostingExtensions
/// Specify the execution environment to use when running the workflows. See
/// , and
/// for the in-process environments.
+ /// If , will include
+ /// in the representing the workflow error.
///
public static AIAgent AsAgent(
this Workflow workflow,
@@ -28,9 +30,10 @@ public static AIAgent AsAgent(
string? name = null,
string? description = null,
CheckpointManager? checkpointManager = null,
- IWorkflowExecutionEnvironment? executionEnvironment = null)
+ IWorkflowExecutionEnvironment? executionEnvironment = null,
+ bool includeExceptionDetails = false)
{
- return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment);
+ return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment, includeExceptionDetails);
}
internal static FunctionCallContent ToFunctionCall(this ExternalRequest request)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs
index 39c83bcadf..87cef04e76 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
+using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -45,14 +46,21 @@ internal sealed class StoreState
internal void AddMessages(params IEnumerable messages) => this._chatMessages.AddRange(messages);
- public override Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default)
+ public override ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ => new(this._chatMessages.AsReadOnly());
+
+ public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
- this._chatMessages.AddRange(messages);
+ if (context.InvokeException is not null)
+ {
+ return default;
+ }
- return Task.CompletedTask;
- }
+ var allNewMessages = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []);
+ this._chatMessages.AddRange(allNewMessages);
- public override Task> GetMessagesAsync(CancellationToken cancellationToken = default) => Task.FromResult>(this._chatMessages.AsReadOnly());
+ return default;
+ }
public IEnumerable GetFromBookmark()
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs
index d27de6bd5c..94144831e0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -17,14 +18,16 @@ internal sealed class WorkflowThread : AgentThread
{
private readonly Workflow _workflow;
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
+ private readonly bool _includeExceptionDetails;
private readonly CheckpointManager _checkpointManager;
private readonly InMemoryCheckpointManager? _inMemoryCheckpointManager;
- public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null)
+ public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, bool includeExceptionDetails = false)
{
this._workflow = Throw.IfNull(workflow);
this._executionEnvironment = Throw.IfNull(executionEnvironment);
+ this._includeExceptionDetails = includeExceptionDetails;
// If the user provided an external checkpoint manager, use that, otherwise rely on an in-memory one.
// TODO: Implement persist-only-last functionality for in-memory checkpoint manager, to avoid unbounded
@@ -35,7 +38,7 @@ public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnviron
this.MessageStore = new WorkflowMessageStore();
}
- public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, JsonSerializerOptions? jsonSerializerOptions = null)
+ public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, bool includeExceptionDetails = false, JsonSerializerOptions? jsonSerializerOptions = null)
{
this._workflow = Throw.IfNull(workflow);
this._executionEnvironment = Throw.IfNull(executionEnvironment);
@@ -80,7 +83,7 @@ public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptio
return marshaller.Marshal(info);
}
- public AgentRunResponseUpdate CreateUpdate(string responseId, params AIContent[] parts)
+ public AgentRunResponseUpdate CreateUpdate(string responseId, object raw, params AIContent[] parts)
{
Throw.IfNullOrEmpty(parts);
@@ -89,7 +92,8 @@ public AgentRunResponseUpdate CreateUpdate(string responseId, params AIContent[]
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Assistant,
- ResponseId = responseId
+ ResponseId = responseId,
+ RawRepresentation = raw
};
this.MessageStore.AddMessages(update.ToChatMessage());
@@ -153,10 +157,29 @@ IAsyncEnumerable InvokeStageAsync(
case RequestInfoEvent requestInfo:
FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
- AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, fcContent);
+ AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent);
yield return update;
break;
+ case WorkflowErrorEvent workflowError:
+ Exception? exception = workflowError.Exception;
+ if (exception is TargetInvocationException tie && tie.InnerException != null)
+ {
+ exception = tie.InnerException;
+ }
+
+ if (exception != null)
+ {
+ string message = this._includeExceptionDetails
+ ? exception.Message
+ : "An error occurred while executing the workflow.";
+
+ ErrorContent errorContent = new(message);
+ yield return this.CreateUpdate(this.LastResponseId, evt, errorContent);
+ }
+
+ break;
+
case SuperStepCompletedEvent stepCompleted:
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
goto default;
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
index f4a7fcd9c2..0fa6473de0 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
@@ -162,7 +162,10 @@ static Task GetResponseAsync(IChatClient chatClient, List RunCoreStreami
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? messages.ToList();
- (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List inputMessagesForChatClient, IList? aiContextProviderMessages) =
+ (ChatClientAgentThread safeThread,
+ ChatOptions? chatOptions,
+ List inputMessagesForChatClient,
+ IList? aiContextProviderMessages,
+ IList? chatMessageStoreMessages,
+ ChatClientAgentContinuationToken? continuationToken) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
- ValidateStreamResumptionAllowed(chatOptions?.ContinuationToken, safeThread);
-
var chatClient = this.ChatClient;
chatClient = ApplyRunOptionsTransformations(options, chatClient);
@@ -214,7 +220,7 @@ protected override async IAsyncEnumerable RunCoreStreami
this._logger.LogAgentChatClientInvokingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
- List responseUpdates = [];
+ List responseUpdates = GetResponseUpdates(continuationToken);
IAsyncEnumerator responseUpdatesEnumerator;
@@ -225,7 +231,8 @@ protected override async IAsyncEnumerable RunCoreStreami
}
catch (Exception ex)
{
- await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
+ await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
+ await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -239,7 +246,8 @@ protected override async IAsyncEnumerable RunCoreStreami
}
catch (Exception ex)
{
- await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
+ await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
+ await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -251,7 +259,12 @@ protected override async IAsyncEnumerable RunCoreStreami
update.AuthorName ??= this.Name;
responseUpdates.Add(update);
- yield return new(update) { AgentId = this.Id };
+
+ yield return new(update)
+ {
+ AgentId = this.Id,
+ ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
+ };
}
try
@@ -260,7 +273,8 @@ protected override async IAsyncEnumerable RunCoreStreami
}
catch (Exception ex)
{
- await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
+ await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
+ await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
}
@@ -272,10 +286,10 @@ protected override async IAsyncEnumerable RunCoreStreami
this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId);
// To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request.
- await NotifyMessageStoreOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
+ await NotifyMessageStoreOfNewMessagesAsync(safeThread, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
// Notify the AIContextProvider of all new messages.
- await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
+ await NotifyAIContextProviderOfSuccessAsync(safeThread, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
}
///
@@ -379,7 +393,12 @@ private async Task RunCoreAsync ?? messages.ToList();
- (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List inputMessagesForChatClient, IList? aiContextProviderMessages) =
+ (ChatClientAgentThread safeThread,
+ ChatOptions? chatOptions,
+ List inputMessagesForChatClient,
+ IList? aiContextProviderMessages,
+ IList? chatMessageStoreMessages,
+ ChatClientAgentContinuationToken? _) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
var chatClient = this.ChatClient;
@@ -398,6 +417,7 @@ private async Task RunCoreAsync RunCoreAsyncOptional run options that may include specific chat configuration settings.
/// A object representing the merged chat configuration, or if
/// neither the run options nor the agent's chat options are available.
- private ChatOptions? CreateConfiguredChatOptions(AgentRunOptions? runOptions)
+ private (ChatOptions?, ChatClientAgentContinuationToken?) CreateConfiguredChatOptions(AgentRunOptions? runOptions)
{
ChatOptions? requestChatOptions = (runOptions as ChatClientAgentRunOptions)?.ChatOptions?.Clone();
// If no agent chat options were provided, return the request chat options as is.
if (this._agentOptions?.ChatOptions is null)
{
- return ApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
+ return GetContinuationTokenAndApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
}
// If no request chat options were provided, use the agent's chat options clone.
if (requestChatOptions is null)
{
- return ApplyBackgroundResponsesProperties(this._agentOptions?.ChatOptions.Clone(), runOptions);
+ return GetContinuationTokenAndApplyBackgroundResponsesProperties(this._agentOptions?.ChatOptions.Clone(), runOptions);
}
// If both are present, we need to merge them.
@@ -579,19 +599,26 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider
}
}
- return ApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
+ return GetContinuationTokenAndApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
- static ChatOptions? ApplyBackgroundResponsesProperties(ChatOptions? chatOptions, AgentRunOptions? agentRunOptions)
+ static (ChatOptions?, ChatClientAgentContinuationToken?) GetContinuationTokenAndApplyBackgroundResponsesProperties(ChatOptions? chatOptions, AgentRunOptions? agentRunOptions)
{
- // If any of the background response properties are set in the run options, we should apply both to the chat options.
- if (agentRunOptions?.AllowBackgroundResponses is not null || agentRunOptions?.ContinuationToken is not null)
+ if (agentRunOptions?.AllowBackgroundResponses is not null)
{
chatOptions ??= new ChatOptions();
chatOptions.AllowBackgroundResponses = agentRunOptions.AllowBackgroundResponses;
- chatOptions.ContinuationToken = agentRunOptions.ContinuationToken;
}
- return chatOptions;
+ ChatClientAgentContinuationToken? agentContinuationToken = null;
+
+ if ((agentRunOptions?.ContinuationToken ?? chatOptions?.ContinuationToken) is { } continuationToken)
+ {
+ agentContinuationToken = ChatClientAgentContinuationToken.FromToken(continuationToken);
+ chatOptions ??= new ChatOptions();
+ chatOptions.ContinuationToken = agentContinuationToken!.InnerToken;
+ }
+
+ return (chatOptions, agentContinuationToken);
}
}
@@ -602,14 +629,22 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider
/// The input messages to use.
/// Optional parameters for agent invocation.
/// The to monitor for cancellation requests. The default is .
- /// A tuple containing the thread, chat options, and thread messages.
- private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List InputMessagesForChatClient, IList? AIContextProviderMessages)> PrepareThreadAndMessagesAsync(
+ /// A tuple containing the thread, chat options, messages and continuation token.
+ private async Task
+ <(
+ ChatClientAgentThread AgentThread,
+ ChatOptions? ChatOptions,
+ List InputMessagesForChatClient,
+ IList? AIContextProviderMessages,
+ IList? ChatMessageStoreMessages,
+ ChatClientAgentContinuationToken? ContinuationToken
+ )> PrepareThreadAndMessagesAsync(
AgentThread? thread,
IEnumerable inputMessages,
AgentRunOptions? runOptions,
CancellationToken cancellationToken)
{
- ChatOptions? chatOptions = this.CreateConfiguredChatOptions(runOptions);
+ (ChatOptions? chatOptions, ChatClientAgentContinuationToken? continuationToken) = this.CreateConfiguredChatOptions(runOptions);
// Supplying a thread for background responses is required to prevent inconsistent experience
// for callers if they forget to provide the thread for initial or follow-up runs.
@@ -630,13 +665,9 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider
throw new InvalidOperationException("Input messages are not allowed when continuing a background response using a continuation token.");
}
- if (chatOptions?.ContinuationToken is not null && typedThread.ConversationId is null && typedThread.MessageStore is null)
- {
- throw new InvalidOperationException("Continuation tokens are not allowed to be used for initial runs.");
- }
-
List inputMessagesForChatClient = [];
IList? aiContextProviderMessages = null;
+ IList? chatMessageStoreMessages = null;
// Populate the thread messages only if we are not continuing an existing response as it's not allowed
if (chatOptions?.ContinuationToken is null)
@@ -644,9 +675,15 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider
// Add any existing messages from the thread to the messages to be sent to the chat client.
if (typedThread.MessageStore is not null)
{
- inputMessagesForChatClient.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
+ var invokingContext = new ChatMessageStore.InvokingContext(inputMessages);
+ var storeMessages = await typedThread.MessageStore.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
+ inputMessagesForChatClient.AddRange(storeMessages);
+ chatMessageStoreMessages = storeMessages as IList ?? storeMessages.ToList();
}
+ // Add the input messages before getting context from AIContextProvider.
+ inputMessagesForChatClient.AddRange(inputMessages);
+
// If we have an AIContextProvider, we should get context from it, and update our
// messages and options with the additional context.
if (typedThread.AIContextProvider is not null)
@@ -675,9 +712,6 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider
chatOptions.Instructions = string.IsNullOrWhiteSpace(chatOptions.Instructions) ? aiContext.Instructions : $"{chatOptions.Instructions}\n{aiContext.Instructions}";
}
}
-
- // Add the input messages to the end of thread messages.
- inputMessagesForChatClient.AddRange(inputMessages);
}
// If a user provided two different thread ids, via the thread object and options, we should throw
@@ -698,7 +732,7 @@ await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProvider
chatOptions.ConversationId = typedThread.ConversationId;
}
- return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages);
+ return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages, chatMessageStoreMessages, continuationToken);
}
private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId)
@@ -725,7 +759,13 @@ private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread,
}
}
- private static Task NotifyMessageStoreOfNewMessagesAsync(ChatClientAgentThread thread, IEnumerable newMessages, CancellationToken cancellationToken)
+ private static Task NotifyMessageStoreOfFailureAsync(
+ ChatClientAgentThread thread,
+ Exception ex,
+ IEnumerable requestMessages,
+ IEnumerable? chatMessageStoreMessages,
+ IEnumerable? aiContextProviderMessages,
+ CancellationToken cancellationToken)
{
var messageStore = thread.MessageStore;
@@ -733,32 +773,80 @@ private static Task NotifyMessageStoreOfNewMessagesAsync(ChatClientAgentThread t
// If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages.
if (messageStore is not null)
{
- return messageStore.AddMessagesAsync(newMessages, cancellationToken);
+ var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages!)
+ {
+ AIContextProviderMessages = aiContextProviderMessages,
+ InvokeException = ex
+ };
+
+ return messageStore.InvokedAsync(invokedContext, cancellationToken).AsTask();
}
return Task.CompletedTask;
}
- private static void ValidateStreamResumptionAllowed(ResponseContinuationToken? continuationToken, ChatClientAgentThread safeThread)
+ private static Task NotifyMessageStoreOfNewMessagesAsync(
+ ChatClientAgentThread thread,
+ IEnumerable requestMessages,
+ IEnumerable? chatMessageStoreMessages,
+ IEnumerable? aiContextProviderMessages,
+ IEnumerable responseMessages,
+ CancellationToken cancellationToken)
{
- if (continuationToken is null)
+ var messageStore = thread.MessageStore;
+
+ // Only notify the message store if we have one.
+ // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages.
+ if (messageStore is not null)
{
- return;
+ var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages!)
+ {
+ AIContextProviderMessages = aiContextProviderMessages,
+ ResponseMessages = responseMessages
+ };
+ return messageStore.InvokedAsync(invokedContext, cancellationToken).AsTask();
}
- // Streaming resumption is only supported with chat history managed by the agent service because, currently, there's no good solution
- // to collect updates received in failed runs and pass them to the last successful run so it can store them to the message store.
- if (safeThread.ConversationId is null)
+ return Task.CompletedTask;
+ }
+
+ private static ChatClientAgentContinuationToken? WrapContinuationToken(ResponseContinuationToken? continuationToken, IEnumerable? inputMessages = null, List? responseUpdates = null)
+ {
+ if (continuationToken is null)
{
- throw new NotSupportedException("Streaming resumption is only supported when chat history is stored and managed by the underlying AI service.");
+ return null;
}
- // Similarly, streaming resumption is not supported when a context provider is used because, currently, there's no good solution
- // to collect updates received in failed runs and pass them to the last successful run so it can notify the context provider of the updates.
- if (safeThread.AIContextProvider is not null)
+ return new(continuationToken)
+ {
+ // Save input messages to the continuation token so they can be added to the thread and
+ // provided to the context provider in the last successful streaming resumption run.
+ // That's necessary for scenarios where initial streaming run is interrupted and streaming is resumed later.
+ InputMessages = inputMessages?.Any() is true ? inputMessages : null,
+
+ // Save all updates received so far to the continuation token so they can be provided to the
+ // message store and context provider in the last successful streaming resumption run.
+ // That's necessary for scenarios where a streaming run is interrupted after some updates were received.
+ ResponseUpdates = responseUpdates?.Count > 0 ? responseUpdates : null
+ };
+ }
+
+ private static IEnumerable GetInputMessages(IReadOnlyCollection inputMessages, ChatClientAgentContinuationToken? token)
+ {
+ // First, use input messages if provided.
+ if (inputMessages.Count > 0)
{
- throw new NotSupportedException("Using context provider with streaming resumption is not supported.");
+ return inputMessages;
}
+
+ // Fallback to messages saved in the continuation token if available.
+ return token?.InputMessages ?? [];
+ }
+
+ private static List GetResponseUpdates(ChatClientAgentContinuationToken? token)
+ {
+ // Restore any previously received updates from the continuation token.
+ return token?.ResponseUpdates?.ToList() ?? [];
}
private string GetLoggingAgentName() => this.Name ?? "UnnamedAgent";
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs
new file mode 100644
index 0000000000..aa5659b1d1
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs
@@ -0,0 +1,170 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Represents a continuation token for ChatClientAgent operations.
+///
+internal class ChatClientAgentContinuationToken : ResponseContinuationToken
+{
+ private const string TokenTypeName = "chatClientAgentContinuationToken";
+ private const string TypeDiscriminator = "type";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A continuation token provided by the underlying .
+ [JsonConstructor]
+ internal ChatClientAgentContinuationToken(ResponseContinuationToken innerToken)
+ {
+ this.InnerToken = innerToken;
+ }
+
+ public override ReadOnlyMemory ToBytes()
+ {
+ using MemoryStream stream = new();
+ using Utf8JsonWriter writer = new(stream);
+
+ writer.WriteStartObject();
+
+ // This property should be the first one written to identify the type during deserialization.
+ writer.WriteString(TypeDiscriminator, TokenTypeName);
+
+ writer.WriteString("innerToken", JsonSerializer.Serialize(this.InnerToken, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))));
+
+ if (this.InputMessages?.Any() is true)
+ {
+ writer.WriteString("inputMessages", JsonSerializer.Serialize(this.InputMessages, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IEnumerable))));
+ }
+
+ if (this.ResponseUpdates?.Count > 0)
+ {
+ writer.WriteString("responseUpdates", JsonSerializer.Serialize(this.ResponseUpdates, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IReadOnlyList))));
+ }
+
+ writer.WriteEndObject();
+
+ writer.Flush();
+
+ return stream.ToArray();
+ }
+
+ ///
+ /// Create a new instance of from the provided .
+ ///
+ /// The token to create the from.
+ /// A equivalent of the provided .
+ internal static ChatClientAgentContinuationToken FromToken(ResponseContinuationToken token)
+ {
+ if (token is ChatClientAgentContinuationToken chatClientContinuationToken)
+ {
+ return chatClientContinuationToken;
+ }
+
+ ReadOnlyMemory data = token.ToBytes();
+
+ if (data.Length == 0)
+ {
+ Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it does not contain any data.");
+ }
+
+ Utf8JsonReader reader = new(data.Span);
+
+ // Move to the start object token.
+ _ = reader.Read();
+
+ // Validate that the token is of this type.
+ ValidateTokenType(reader, token);
+
+ ResponseContinuationToken? innerToken = null;
+ IEnumerable? inputMessages = null;
+ IReadOnlyList? responseUpdates = null;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ {
+ break;
+ }
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ continue;
+ }
+ switch (reader.GetString())
+ {
+ case "innerToken":
+ _ = reader.Read();
+ var innerTokenJson = reader.GetString() ?? throw new ArgumentException("No content for innerToken property.", nameof(token));
+ innerToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(innerTokenJson, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
+ break;
+ case "inputMessages":
+ _ = reader.Read();
+ var innerMessagesJson = reader.GetString() ?? throw new ArgumentException("No content for inputMessages property.", nameof(token));
+ inputMessages = (IEnumerable?)JsonSerializer.Deserialize(innerMessagesJson, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IEnumerable)));
+ break;
+ case "responseUpdates":
+ _ = reader.Read();
+ var responseUpdatesJson = reader.GetString() ?? throw new ArgumentException("No content for responseUpdates property.", nameof(token));
+ responseUpdates = (IReadOnlyList?)JsonSerializer.Deserialize(responseUpdatesJson, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IReadOnlyList)));
+ break;
+ default:
+ break;
+ }
+ }
+
+ if (innerToken is null)
+ {
+ Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it does not contain an inner token.");
+ }
+
+ return new ChatClientAgentContinuationToken(innerToken)
+ {
+ InputMessages = inputMessages,
+ ResponseUpdates = responseUpdates
+ };
+ }
+
+ private static void ValidateTokenType(Utf8JsonReader reader, ResponseContinuationToken token)
+ {
+ try
+ {
+ // Move to the first property.
+ _ = reader.Read();
+
+ // If the first property name is not "type", or its value does not match this token type name, then we know its not this token type.
+ if (reader.GetString() != TypeDiscriminator || !reader.Read() || reader.GetString() != TokenTypeName)
+ {
+ Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it is not of the correct type.");
+ }
+ }
+ catch (JsonException ex)
+ {
+ Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it could not be parsed.", ex);
+ }
+ }
+
+ ///
+ /// Gets a continuation token provided by the underlying .
+ ///
+ internal ResponseContinuationToken InnerToken { get; }
+
+ ///
+ /// Gets or sets the input messages used for streaming run.
+ ///
+ internal IEnumerable? InputMessages { get; set; }
+
+ ///
+ /// Gets or sets the response updates received so far.
+ ///
+ internal IReadOnlyList? ResponseUpdates { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs
new file mode 100644
index 0000000000..b0cbd3d793
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs
@@ -0,0 +1,253 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Provides extension methods for to enable discoverability of .
+///
+public partial class ChatClientAgent
+{
+ ///
+ /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread.
+ ///
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with any response messages generated during invocation.
+ ///
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ /// The to monitor for cancellation requests. The default is .
+ /// A task that represents the asynchronous operation. The task result contains an with the agent's output.
+ public Task RunAsync(
+ AgentThread? thread,
+ ChatClientAgentRunOptions? options,
+ CancellationToken cancellationToken = default) =>
+ this.RunAsync(thread, (AgentRunOptions?)options, cancellationToken);
+
+ ///
+ /// Runs the agent with a text message from the user.
+ ///
+ /// The user message to send to the agent.
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with the input message and any response messages generated during invocation.
+ ///
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ /// The to monitor for cancellation requests. The default is .
+ /// A task that represents the asynchronous operation. The task result contains an with the agent's output.
+ public Task RunAsync(
+ string message,
+ AgentThread? thread,
+ ChatClientAgentRunOptions? options,
+ CancellationToken cancellationToken = default) =>
+ this.RunAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
+
+ ///
+ /// Runs the agent with a single chat message.
+ ///
+ /// The chat message to send to the agent.
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with the input message and any response messages generated during invocation.
+ ///
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ /// The to monitor for cancellation requests. The default is .
+ /// A task that represents the asynchronous operation. The task result contains an with the agent's output.
+ public Task RunAsync(
+ ChatMessage message,
+ AgentThread? thread,
+ ChatClientAgentRunOptions? options,
+ CancellationToken cancellationToken = default) =>
+ this.RunAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
+
+ ///
+ /// Runs the agent with a collection of chat messages.
+ ///
+ /// The collection of messages to send to the agent for processing.
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with the input messages and any response messages generated during invocation.
+ ///
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ /// The to monitor for cancellation requests. The default is .
+ /// A task that represents the asynchronous operation. The task result contains an with the agent's output.
+ public Task RunAsync(
+ IEnumerable messages,
+ AgentThread? thread,
+ ChatClientAgentRunOptions? options,
+ CancellationToken cancellationToken = default) =>
+ this.RunAsync(messages, thread, (AgentRunOptions?)options, cancellationToken);
+
+ ///
+ /// Runs the agent in streaming mode without providing new input messages, relying on existing context and instructions.
+ ///
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with any response messages generated during invocation.
+ ///
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ /// The to monitor for cancellation requests. The default is .
+ /// An asynchronous enumerable of instances representing the streaming response.
+ public IAsyncEnumerable RunStreamingAsync(
+ AgentThread? thread,
+ ChatClientAgentRunOptions? options,
+ CancellationToken cancellationToken = default) =>
+ this.RunStreamingAsync(thread, (AgentRunOptions?)options, cancellationToken);
+
+ ///
+ /// Runs the agent in streaming mode with a text message from the user.
+ ///
+ /// The user message to send to the agent.
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with the input message and any response messages generated during invocation.
+ ///
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ /// The to monitor for cancellation requests. The default is .
+ /// An asynchronous enumerable of instances representing the streaming response.
+ public IAsyncEnumerable RunStreamingAsync(
+ string message,
+ AgentThread? thread,
+ ChatClientAgentRunOptions? options,
+ CancellationToken cancellationToken = default) =>
+ this.RunStreamingAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
+
+ ///
+ /// Runs the agent in streaming mode with a single chat message.
+ ///
+ /// The chat message to send to the agent.
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with the input message and any response messages generated during invocation.
+ ///
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ /// The to monitor for cancellation requests. The default is .
+ /// An asynchronous enumerable of instances representing the streaming response.
+ public IAsyncEnumerable RunStreamingAsync(
+ ChatMessage message,
+ AgentThread? thread,
+ ChatClientAgentRunOptions? options,
+ CancellationToken cancellationToken = default) =>
+ this.RunStreamingAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
+
+ ///
+ /// Runs the agent in streaming mode with a collection of chat messages.
+ ///
+ /// The collection of messages to send to the agent for processing.
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with the input messages and any response updates generated during invocation.
+ ///
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ /// The to monitor for cancellation requests. The default is .
+ /// An asynchronous enumerable of instances representing the streaming response.
+ public IAsyncEnumerable RunStreamingAsync(
+ IEnumerable messages,
+ AgentThread? thread,
+ ChatClientAgentRunOptions? options,
+ CancellationToken cancellationToken = default) =>
+ this.RunStreamingAsync(messages, thread, (AgentRunOptions?)options, cancellationToken);
+
+ ///
+ /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread, and requesting a response of the specified type .
+ ///
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with any response messages generated during invocation.
+ ///
+ /// The JSON serialization options to use.
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ ///
+ /// to set a JSON schema on the ; otherwise, . The default is .
+ /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
+ ///
+ /// The to monitor for cancellation requests. The default is .
+ /// A task that represents the asynchronous operation. The task result contains an with the agent's output.
+ public Task> RunAsync(
+ AgentThread? thread,
+ JsonSerializerOptions? serializerOptions,
+ ChatClientAgentRunOptions? options,
+ bool? useJsonSchemaResponseFormat = null,
+ CancellationToken cancellationToken = default) =>
+ this.RunAsync(thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
+
+ ///
+ /// Runs the agent with a text message from the user, requesting a response of the specified type .
+ ///
+ /// The user message to send to the agent.
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with the input message and any response messages generated during invocation.
+ ///
+ /// The JSON serialization options to use.
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ ///
+ /// to set a JSON schema on the ; otherwise, . The default is .
+ /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
+ ///
+ /// The to monitor for cancellation requests. The default is .
+ /// A task that represents the asynchronous operation. The task result contains an with the agent's output.
+ public Task> RunAsync(
+ string message,
+ AgentThread? thread,
+ JsonSerializerOptions? serializerOptions,
+ ChatClientAgentRunOptions? options,
+ bool? useJsonSchemaResponseFormat = null,
+ CancellationToken cancellationToken = default) =>
+ this.RunAsync(message, thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
+
+ ///
+ /// Runs the agent with a single chat message, requesting a response of the specified type .
+ ///
+ /// The chat message to send to the agent.
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with the input message and any response messages generated during invocation.
+ ///
+ /// The JSON serialization options to use.
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ ///
+ /// to set a JSON schema on the ; otherwise, . The default is .
+ /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
+ ///
+ /// The to monitor for cancellation requests. The default is .
+ /// A task that represents the asynchronous operation. The task result contains an with the agent's output.
+ public Task> RunAsync(
+ ChatMessage message,
+ AgentThread? thread,
+ JsonSerializerOptions? serializerOptions,
+ ChatClientAgentRunOptions? options,
+ bool? useJsonSchemaResponseFormat = null,
+ CancellationToken cancellationToken = default) =>
+ this.RunAsync(message, thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
+
+ ///
+ /// Runs the agent with a collection of chat messages, requesting a response of the specified type .
+ ///
+ /// The collection of messages to send to the agent for processing.
+ ///
+ /// The conversation thread to use for this invocation. If , a new thread will be created.
+ /// The thread will be updated with the input messages and any response messages generated during invocation.
+ ///
+ /// The JSON serialization options to use.
+ /// Configuration parameters for controlling the agent's invocation behavior.
+ ///
+ /// to set a JSON schema on the ; otherwise, . The default is .
+ /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
+ ///
+ /// The to monitor for cancellation requests. The default is .
+ /// A task that represents the asynchronous operation. The task result contains an with the agent's output.
+ public Task> RunAsync(
+ IEnumerable messages,
+ AgentThread? thread,
+ JsonSerializerOptions? serializerOptions,
+ ChatClientAgentRunOptions? options,
+ bool? useJsonSchemaResponseFormat = null,
+ CancellationToken cancellationToken = default) =>
+ this.RunAsync(messages, thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
index 4a72d66f2d..dd1ff3b228 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
@@ -80,7 +80,7 @@ public ChatClientAgentOptions Clone()
///
/// Context object passed to the to create a new instance of .
///
- public class AIContextProviderFactoryContext
+ public sealed class AIContextProviderFactoryContext
{
///
/// Gets or sets the serialized state of the , if any.
@@ -97,7 +97,7 @@ public class AIContextProviderFactoryContext
///
/// Context object passed to the to create a new instance of .
///
- public class ChatMessageStoreFactoryContext
+ public sealed class ChatMessageStoreFactoryContext
{
///
/// Gets or sets the serialized state of the chat message store, if any.
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs
index 13b536a457..352be764eb 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs
@@ -40,7 +40,6 @@ public ChatClientAgentRunResponse(ChatResponse response) : base(response)
///
///
/// If the response did not contain JSON, or if deserialization fails, this property will throw.
- /// To avoid exceptions, use instead.
///
public override T Result => this._response.Result;
}
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs
index 913be969c6..9a535cd645 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs
@@ -154,7 +154,10 @@ async Task> GetResponseAsync(IChatClient chatClient, List CreateResponse(ChatResponse chatResponse)
{
- return new ChatClientAgentRunResponse(chatResponse);
+ return new ChatClientAgentRunResponse(chatResponse)
+ {
+ ContinuationToken = WrapContinuationToken(chatResponse.ContinuationToken)
+ };
}
return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, thread, options, cancellationToken);
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs
index 7f0ce9a1ea..f4cf4aa033 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI;
/// Provides a thread implementation for use with .
///
[DebuggerDisplay("{DebuggerDisplay,nq}")]
-public class ChatClientAgentThread : AgentThread
+public sealed class ChatClientAgentThread : AgentThread
{
private ChatMessageStore? _messageStore;
@@ -171,9 +171,7 @@ public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptio
///
public override object? GetService(Type serviceType, object? serviceKey = null) =>
- serviceType == typeof(AgentThreadMetadata)
- ? new AgentThreadMetadata(this.ConversationId)
- : base.GetService(serviceType, serviceKey)
+ base.GetService(serviceType, serviceKey)
?? this.AIContextProvider?.GetService(serviceType, serviceKey)
?? this.MessageStore?.GetService(serviceType, serviceKey);
diff --git a/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs
index 72c0b14ae2..2bec0b366e 100644
--- a/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs
+++ b/dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletionFixture.cs
@@ -39,7 +39,12 @@ public async Task> GetChatHistoryAsync(AgentThread thread)
{
var typedThread = (ChatClientAgentThread)thread;
- return typedThread.MessageStore is null ? [] : (await typedThread.MessageStore.GetMessagesAsync()).ToList();
+ if (typedThread.MessageStore is null)
+ {
+ return [];
+ }
+
+ return (await typedThread.MessageStore.InvokingAsync(new([]))).ToList();
}
public Task CreateChatClientAgentAsync(
diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs
index 883b317f5e..ddb015eb17 100644
--- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs
+++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs
@@ -48,7 +48,12 @@ public async Task> GetChatHistoryAsync(AgentThread thread)
return await this.GetChatHistoryFromResponsesChainAsync(chatClientThread.ConversationId);
}
- return chatClientThread.MessageStore is null ? [] : (await chatClientThread.MessageStore.GetMessagesAsync()).ToList();
+ if (chatClientThread.MessageStore is null)
+ {
+ return [];
+ }
+
+ return (await chatClientThread.MessageStore.InvokingAsync(new([]))).ToList();
}
private async Task> GetChatHistoryFromResponsesChainAsync(string conversationId)
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
index 0b491fb303..236ae7b332 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
@@ -832,6 +832,174 @@ await Assert.ThrowsAsync(async () =>
});
}
+ [Fact]
+ public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdditionalPropertiesAsync()
+ {
+ // Arrange
+ this._handler.ResponseToReturn = new AgentMessage
+ {
+ MessageId = "response-123",
+ Role = MessageRole.Agent,
+ Parts = [new TextPart { Text = "Response with metadata" }],
+ Metadata = new Dictionary
+ {
+ { "responseKey1", JsonSerializer.SerializeToElement("responseValue1") },
+ { "responseCount", JsonSerializer.SerializeToElement(99) }
+ }
+ };
+
+ var inputMessages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+
+ // Act
+ var result = await this._agent.RunAsync(inputMessages);
+
+ // Assert
+ Assert.NotNull(result.AdditionalProperties);
+ Assert.NotNull(result.AdditionalProperties["responseKey1"]);
+ Assert.Equal("responseValue1", ((JsonElement)result.AdditionalProperties["responseKey1"]!).GetString());
+ Assert.NotNull(result.AdditionalProperties["responseCount"]);
+ Assert.Equal(99, ((JsonElement)result.AdditionalProperties["responseCount"]!).GetInt32());
+ }
+
+ [Fact]
+ public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync()
+ {
+ // Arrange
+ this._handler.ResponseToReturn = new AgentMessage
+ {
+ MessageId = "response-123",
+ Role = MessageRole.Agent,
+ Parts = [new TextPart { Text = "Response" }]
+ };
+
+ var inputMessages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+
+ var options = new AgentRunOptions
+ {
+ AdditionalProperties = new()
+ {
+ { "key1", "value1" },
+ { "key2", 42 },
+ { "key3", true }
+ }
+ };
+
+ // Act
+ await this._agent.RunAsync(inputMessages, null, options);
+
+ // Assert
+ Assert.NotNull(this._handler.CapturedMessageSendParams);
+ Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata);
+ Assert.Equal("value1", this._handler.CapturedMessageSendParams.Metadata["key1"].GetString());
+ Assert.Equal(42, this._handler.CapturedMessageSendParams.Metadata["key2"].GetInt32());
+ Assert.True(this._handler.CapturedMessageSendParams.Metadata["key3"].GetBoolean());
+ }
+
+ [Fact]
+ public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync()
+ {
+ // Arrange
+ this._handler.ResponseToReturn = new AgentMessage
+ {
+ MessageId = "response-123",
+ Role = MessageRole.Agent,
+ Parts = [new TextPart { Text = "Response" }]
+ };
+
+ var inputMessages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+
+ var options = new AgentRunOptions
+ {
+ AdditionalProperties = null
+ };
+
+ // Act
+ await this._agent.RunAsync(inputMessages, null, options);
+
+ // Assert
+ Assert.NotNull(this._handler.CapturedMessageSendParams);
+ Assert.Null(this._handler.CapturedMessageSendParams.Metadata);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync()
+ {
+ // Arrange
+ this._handler.StreamingResponseToReturn = new AgentMessage
+ {
+ MessageId = "stream-123",
+ Role = MessageRole.Agent,
+ Parts = [new TextPart { Text = "Streaming response" }]
+ };
+
+ var inputMessages = new List
+ {
+ new(ChatRole.User, "Test streaming message")
+ };
+
+ var options = new AgentRunOptions
+ {
+ AdditionalProperties = new()
+ {
+ { "streamKey1", "streamValue1" },
+ { "streamKey2", 100 },
+ { "streamKey3", false }
+ }
+ };
+
+ // Act
+ await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options))
+ {
+ }
+
+ // Assert
+ Assert.NotNull(this._handler.CapturedMessageSendParams);
+ Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata);
+ Assert.Equal("streamValue1", this._handler.CapturedMessageSendParams.Metadata["streamKey1"].GetString());
+ Assert.Equal(100, this._handler.CapturedMessageSendParams.Metadata["streamKey2"].GetInt32());
+ Assert.False(this._handler.CapturedMessageSendParams.Metadata["streamKey3"].GetBoolean());
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync()
+ {
+ // Arrange
+ this._handler.StreamingResponseToReturn = new AgentMessage
+ {
+ MessageId = "stream-123",
+ Role = MessageRole.Agent,
+ Parts = [new TextPart { Text = "Streaming response" }]
+ };
+
+ var inputMessages = new List
+ {
+ new(ChatRole.User, "Test streaming message")
+ };
+
+ var options = new AgentRunOptions
+ {
+ AdditionalProperties = null
+ };
+
+ // Act
+ await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options))
+ {
+ }
+
+ // Assert
+ Assert.NotNull(this._handler.CapturedMessageSendParams);
+ Assert.Null(this._handler.CapturedMessageSendParams.Metadata);
+ }
+
[Fact]
public async Task RunAsync_WithInvalidThreadType_ThrowsInvalidOperationExceptionAsync()
{
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs
new file mode 100644
index 0000000000..4972b8857f
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/AdditionalPropertiesDictionaryExtensionsTests.cs
@@ -0,0 +1,186 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.A2A.UnitTests;
+
+///
+/// Unit tests for the class.
+///
+public sealed class AdditionalPropertiesDictionaryExtensionsTests
+{
+ [Fact]
+ public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary? additionalProperties = null;
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = [];
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "stringKey", "stringValue" }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("stringKey"));
+ Assert.Equal("stringValue", result["stringKey"].GetString());
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "numberKey", 42 }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("numberKey"));
+ Assert.Equal(42, result["numberKey"].GetInt32());
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "booleanKey", true }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("booleanKey"));
+ Assert.True(result["booleanKey"].GetBoolean());
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "stringKey", "stringValue" },
+ { "numberKey", 42 },
+ { "booleanKey", true }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(3, result.Count);
+
+ Assert.True(result.ContainsKey("stringKey"));
+ Assert.Equal("stringValue", result["stringKey"].GetString());
+
+ Assert.True(result.ContainsKey("numberKey"));
+ Assert.Equal(42, result["numberKey"].GetInt32());
+
+ Assert.True(result.ContainsKey("booleanKey"));
+ Assert.True(result["booleanKey"].GetBoolean());
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement()
+ {
+ // Arrange
+ int[] arrayValue = [1, 2, 3];
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "arrayKey", arrayValue }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("arrayKey"));
+ Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind);
+ Assert.Equal(3, result["arrayKey"].GetArrayLength());
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "nullKey", null! }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("nullKey"));
+ Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind);
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement()
+ {
+ // Arrange
+ JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 });
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "jsonElementKey", jsonElement }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("jsonElementKey"));
+ Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind);
+ Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString());
+ Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32());
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseTests.cs
index 981f1e3933..8e39b4c4fa 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseTests.cs
@@ -57,7 +57,7 @@ public void ConstructorWithChatResponseRoundtrips()
RawRepresentation = new object(),
ResponseId = "responseId",
Usage = new UsageDetails(),
- ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
+ ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })
};
AgentRunResponse response = new(chatResponse);
@@ -214,6 +214,12 @@ public void ToAgentRunResponseUpdatesProducesUpdates()
Assert.Equal(100, usageContent.Details.TotalTokenCount);
}
+#if NETFRAMEWORK
+ ///
+ /// Since Json Serialization using reflection is disabled in .net core builds, and we are using a custom type here that wouldn't
+ /// be registered with the default source generated serializer, this test will only pass in .net framework builds where reflection-based
+ /// serialization is available.
+ ///
[Fact]
public void ParseAsStructuredOutputSuccess()
{
@@ -221,6 +227,24 @@ public void ParseAsStructuredOutputSuccess()
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
+ // Act.
+ var animal = response.Deserialize();
+
+ // Assert.
+ Assert.NotNull(animal);
+ Assert.Equal(expectedResult.Id, animal.Id);
+ Assert.Equal(expectedResult.FullName, animal.FullName);
+ Assert.Equal(expectedResult.Species, animal.Species);
+ }
+#endif
+
+ [Fact]
+ public void ParseAsStructuredOutputWithJSOSuccess()
+ {
+ // Arrange.
+ var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
+ var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
+
// Act.
var animal = response.Deserialize(TestJsonSerializerContext.Default.Options);
@@ -262,6 +286,12 @@ public void ParseAsStructuredOutputFailsWithIncorrectTypedJson()
Assert.Throws(() => response.Deserialize(TestJsonSerializerContext.Default.Options));
}
+#if NETFRAMEWORK
+ ///
+ /// Since Json Serialization using reflection is disabled in .net core builds, and we are using a custom type here that wouldn't
+ /// be registered with the default source generated serializer, this test will only pass in .net framework builds where reflection-based
+ /// serialization is available.
+ ///
[Fact]
public void TryParseAsStructuredOutputSuccess()
{
@@ -269,6 +299,24 @@ public void TryParseAsStructuredOutputSuccess()
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
+ // Act.
+ response.TryDeserialize(out Animal? animal);
+
+ // Assert.
+ Assert.NotNull(animal);
+ Assert.Equal(expectedResult.Id, animal.Id);
+ Assert.Equal(expectedResult.FullName, animal.FullName);
+ Assert.Equal(expectedResult.Species, animal.Species);
+ }
+#endif
+
+ [Fact]
+ public void TryParseAsStructuredOutputWithJSOSuccess()
+ {
+ // Arrange.
+ var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
+ var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
+
// Act.
response.TryDeserialize(TestJsonSerializerContext.Default.Options, out Animal? animal);
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreMessageFilterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreMessageFilterTests.cs
new file mode 100644
index 0000000000..ab10c377ae
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreMessageFilterTests.cs
@@ -0,0 +1,205 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Moq;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests;
+
+///
+/// Contains tests for the class.
+///
+public sealed class ChatMessageStoreMessageFilterTests
+{
+ [Fact]
+ public void Constructor_WithNullInnerStore_ThrowsArgumentNullException()
+ {
+ // Arrange, Act & Assert
+ Assert.Throws(() => new ChatMessageStoreMessageFilter(null!));
+ }
+
+ [Fact]
+ public void Constructor_WithOnlyInnerStore_Throws()
+ {
+ // Arrange
+ var innerStoreMock = new Mock();
+
+ // Act & Assert
+ Assert.Throws(() => new ChatMessageStoreMessageFilter(innerStoreMock.Object));
+ }
+
+ [Fact]
+ public void Constructor_WithAllParameters_CreatesInstance()
+ {
+ // Arrange
+ var innerStoreMock = new Mock();
+
+ IEnumerable InvokingFilter(IEnumerable msgs) => msgs;
+ ChatMessageStore.InvokedContext InvokedFilter(ChatMessageStore.InvokedContext ctx) => ctx;
+
+ // Act
+ var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, InvokingFilter, InvokedFilter);
+
+ // Assert
+ Assert.NotNull(filter);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_WithNoOpFilters_ReturnsInnerStoreMessagesAsync()
+ {
+ // Arrange
+ var innerStoreMock = new Mock();
+ var expectedMessages = new List
+ {
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi there!")
+ };
+ var context = new ChatMessageStore.InvokingContext([new ChatMessage(ChatRole.User, "Test")]);
+
+ innerStoreMock
+ .Setup(s => s.InvokingAsync(context, It.IsAny()))
+ .ReturnsAsync(expectedMessages);
+
+ var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, x => x, x => x);
+
+ // Act
+ var result = (await filter.InvokingAsync(context, CancellationToken.None)).ToList();
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.Equal("Hello", result[0].Text);
+ Assert.Equal("Hi there!", result[1].Text);
+ innerStoreMock.Verify(s => s.InvokingAsync(context, It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_WithInvokingFilter_AppliesFilterAsync()
+ {
+ // Arrange
+ var innerStoreMock = new Mock();
+ var innerMessages = new List
+ {
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi there!"),
+ new(ChatRole.User, "How are you?")
+ };
+ var context = new ChatMessageStore.InvokingContext([new ChatMessage(ChatRole.User, "Test")]);
+
+ innerStoreMock
+ .Setup(s => s.InvokingAsync(context, It.IsAny()))
+ .ReturnsAsync(innerMessages);
+
+ // Filter to only user messages
+ IEnumerable InvokingFilter(IEnumerable msgs) => msgs.Where(m => m.Role == ChatRole.User);
+
+ var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, InvokingFilter);
+
+ // Act
+ var result = (await filter.InvokingAsync(context, CancellationToken.None)).ToList();
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.All(result, msg => Assert.Equal(ChatRole.User, msg.Role));
+ innerStoreMock.Verify(s => s.InvokingAsync(context, It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task InvokingAsync_WithInvokingFilter_CanModifyMessagesAsync()
+ {
+ // Arrange
+ var innerStoreMock = new Mock();
+ var innerMessages = new List
+ {
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi there!")
+ };
+ var context = new ChatMessageStore.InvokingContext([new ChatMessage(ChatRole.User, "Test")]);
+
+ innerStoreMock
+ .Setup(s => s.InvokingAsync(context, It.IsAny()))
+ .ReturnsAsync(innerMessages);
+
+ // Filter that transforms messages
+ IEnumerable InvokingFilter(IEnumerable msgs) =>
+ msgs.Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}"));
+
+ var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, InvokingFilter);
+
+ // Act
+ var result = (await filter.InvokingAsync(context, CancellationToken.None)).ToList();
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.Equal("[FILTERED] Hello", result[0].Text);
+ Assert.Equal("[FILTERED] Hi there!", result[1].Text);
+ }
+
+ [Fact]
+ public async Task InvokedAsync_WithInvokedFilter_AppliesFilterAsync()
+ {
+ // Arrange
+ var innerStoreMock = new Mock();
+ var requestMessages = new List { new(ChatRole.User, "Hello") };
+ var chatMessageStoreMessages = new List { new(ChatRole.System, "System") };
+ var responseMessages = new List { new(ChatRole.Assistant, "Response") };
+ var context = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages)
+ {
+ ResponseMessages = responseMessages
+ };
+
+ ChatMessageStore.InvokedContext? capturedContext = null;
+ innerStoreMock
+ .Setup(s => s.InvokedAsync(It.IsAny(), It.IsAny()))
+ .Callback((ctx, ct) => capturedContext = ctx)
+ .Returns(default(ValueTask));
+
+ // Filter that modifies the context
+ ChatMessageStore.InvokedContext InvokedFilter(ChatMessageStore.InvokedContext ctx)
+ {
+ var modifiedRequestMessages = ctx.RequestMessages.Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}")).ToList();
+ return new ChatMessageStore.InvokedContext(modifiedRequestMessages, ctx.ChatMessageStoreMessages)
+ {
+ ResponseMessages = ctx.ResponseMessages,
+ AIContextProviderMessages = ctx.AIContextProviderMessages,
+ InvokeException = ctx.InvokeException
+ };
+ }
+
+ var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, invokedMessagesFilter: InvokedFilter);
+
+ // Act
+ await filter.InvokedAsync(context, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(capturedContext);
+ Assert.Single(capturedContext.RequestMessages);
+ Assert.Equal("[FILTERED] Hello", capturedContext.RequestMessages.First().Text);
+ innerStoreMock.Verify(s => s.InvokedAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public void Serialize_DelegatesToInnerStore()
+ {
+ // Arrange
+ var innerStoreMock = new Mock();
+ var expectedJson = JsonSerializer.SerializeToElement("data", TestJsonSerializerContext.Default.String);
+
+ innerStoreMock
+ .Setup(s => s.Serialize(It.IsAny()))
+ .Returns(expectedJson);
+
+ var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, x => x, x => x);
+
+ // Act
+ var result = filter.Serialize();
+
+ // Assert
+ Assert.Equal(expectedJson.GetRawText(), result.GetRawText());
+ innerStoreMock.Verify(s => s.Serialize(null), Times.Once);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreTests.cs
index 4100b20f5a..883941458c 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageStoreTests.cs
@@ -78,11 +78,11 @@ public void GetService_Generic_ReturnsNullForUnrelatedType()
private sealed class TestChatMessageStore : ChatMessageStore
{
- public override Task> GetMessagesAsync(CancellationToken cancellationToken = default)
- => Task.FromResult>([]);
+ public override ValueTask> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ => new(Array.Empty());
- public override Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default)
- => Task.CompletedTask;
+ public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
+ => default;
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
=> default;
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs
index 824fb62f6d..43bfacca79 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs
@@ -47,34 +47,54 @@ public void Constructor_Arguments_SetOnPropertiesCorrectly()
}
[Fact]
- public async Task AddMessagesAsyncAddsMessagesAndReturnsNullThreadIdAsync()
+ public async Task InvokedAsyncAddsMessagesAsync()
{
- var store = new InMemoryChatMessageStore();
- var messages = new List
+ var requestMessages = new List
+ {
+ new(ChatRole.User, "Hello")
+ };
+ var responseMessages = new List
{
- new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there!")
};
+ var messageStoreMessages = new List()
+ {
+ new(ChatRole.System, "original instructions")
+ };
+ var aiContextProviderMessages = new List()
+ {
+ new(ChatRole.System, "additional context")
+ };
- await store.AddMessagesAsync(messages, CancellationToken.None);
+ var store = new InMemoryChatMessageStore();
+ store.Add(messageStoreMessages[0]);
+ var context = new ChatMessageStore.InvokedContext(requestMessages, messageStoreMessages)
+ {
+ AIContextProviderMessages = aiContextProviderMessages,
+ ResponseMessages = responseMessages
+ };
+ await store.InvokedAsync(context, CancellationToken.None);
- Assert.Equal(2, store.Count);
- Assert.Equal("Hello", store[0].Text);
- Assert.Equal("Hi there!", store[1].Text);
+ Assert.Equal(4, store.Count);
+ Assert.Equal("original instructions", store[0].Text);
+ Assert.Equal("Hello", store[1].Text);
+ Assert.Equal("additional context", store[2].Text);
+ Assert.Equal("Hi there!", store[3].Text);
}
[Fact]
- public async Task AddMessagesAsyncWithEmptyDoesNotFailAsync()
+ public async Task InvokedAsyncWithEmptyDoesNotFailAsync()
{
var store = new InMemoryChatMessageStore();
- await store.AddMessagesAsync([], CancellationToken.None);
+ var context = new ChatMessageStore.InvokedContext([], []);
+ await store.InvokedAsync(context, CancellationToken.None);
Assert.Empty(store);
}
[Fact]
- public async Task GetMessagesAsyncReturnsAllMessagesAsync()
+ public async Task InvokingAsyncReturnsAllMessagesAsync()
{
var store = new InMemoryChatMessageStore
{
@@ -82,7 +102,8 @@ public async Task GetMessagesAsyncReturnsAllMessagesAsync()
new ChatMessage(ChatRole.Assistant, "Test2")
};
- var result = (await store.GetMessagesAsync(CancellationToken.None)).ToList();
+ var context = new ChatMessageStore.InvokingContext([]);
+ var result = (await store.InvokingAsync(context, CancellationToken.None)).ToList();
Assert.Equal(2, result.Count);
Assert.Contains(result, m => m.Text == "Test1");
@@ -157,24 +178,25 @@ public async Task SerializeAndDeserializeWorksWithExperimentalContentTypesAsync(
}
[Fact]
- public async Task AddMessagesAsyncWithEmptyMessagesDoesNotChangeStoreAsync()
+ public async Task InvokedAsyncWithEmptyMessagesDoesNotChangeStoreAsync()
{
var store = new InMemoryChatMessageStore();
var messages = new List();
- await store.AddMessagesAsync(messages, CancellationToken.None);
+ var context = new ChatMessageStore.InvokedContext(messages, []);
+ await store.InvokedAsync(context, CancellationToken.None);
Assert.Empty(store);
}
[Fact]
- public async Task AddMessagesAsync_WithNullMessages_ThrowsArgumentNullExceptionAsync()
+ public async Task InvokedAsync_WithNullContext_ThrowsArgumentNullExceptionAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
// Act & Assert
- await Assert.ThrowsAsync(() => store.AddMessagesAsync(null!, CancellationToken.None));
+ await Assert.ThrowsAsync(() => store.InvokedAsync(null!, CancellationToken.None).AsTask());
}
[Fact]
@@ -498,7 +520,8 @@ public async Task AddMessagesAsync_WithReducer_AfterMessageAdded_InvokesReducerA
var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.AfterMessageAdded);
// Act
- await store.AddMessagesAsync(originalMessages, CancellationToken.None);
+ var context = new ChatMessageStore.InvokedContext(originalMessages, []);
+ await store.InvokedAsync(context, CancellationToken.None);
// Assert
Assert.Single(store);
@@ -526,10 +549,15 @@ public async Task GetMessagesAsync_WithReducer_BeforeMessagesRetrieval_InvokesRe
.ReturnsAsync(reducedMessages);
var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.BeforeMessagesRetrieval);
- await store.AddMessagesAsync(originalMessages, CancellationToken.None);
+ // Add messages directly to the store for this test
+ foreach (var msg in originalMessages)
+ {
+ store.Add(msg);
+ }
// Act
- var result = (await store.GetMessagesAsync(CancellationToken.None)).ToList();
+ var invokingContext = new ChatMessageStore.InvokingContext(Array.Empty());
+ var result = (await store.InvokingAsync(invokingContext, CancellationToken.None)).ToList();
// Assert
Assert.Single(result);
@@ -551,7 +579,8 @@ public async Task AddMessagesAsync_WithReducer_ButWrongTrigger_DoesNotInvokeRedu
var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.BeforeMessagesRetrieval);
// Act
- await store.AddMessagesAsync(originalMessages, CancellationToken.None);
+ var context = new ChatMessageStore.InvokedContext(originalMessages, []);
+ await store.InvokedAsync(context, CancellationToken.None);
// Assert
Assert.Single(store);
@@ -576,7 +605,8 @@ public async Task GetMessagesAsync_WithReducer_ButWrongTrigger_DoesNotInvokeRedu
};
// Act
- var result = (await store.GetMessagesAsync(CancellationToken.None)).ToList();
+ var invokingContext = new ChatMessageStore.InvokingContext(Array.Empty());
+ var result = (await store.InvokingAsync(invokingContext, CancellationToken.None)).ToList();
// Assert
Assert.Single(result);
diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs
index 3dbd3ec367..9410e68f1b 100644
--- a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatMessageStoreTests.cs
@@ -202,11 +202,11 @@ public void Constructor_WithEmptyConversationId_ShouldThrowArgumentException()
#endregion
- #region AddMessagesAsync Tests
+ #region InvokedAsync Tests
[SkippableFact]
[Trait("Category", "CosmosDB")]
- public async Task AddMessagesAsync_WithSingleMessage_ShouldAddMessageAsync()
+ public async Task InvokedAsync_WithSingleMessage_ShouldAddMessageAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
@@ -214,14 +214,20 @@ public async Task AddMessagesAsync_WithSingleMessage_ShouldAddMessageAsync()
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
var message = new ChatMessage(ChatRole.User, "Hello, world!");
+ var context = new ChatMessageStore.InvokedContext([message], [])
+ {
+ ResponseMessages = []
+ };
+
// Act
- await store.AddMessagesAsync([message]);
+ await store.InvokedAsync(context);
// Wait a moment for eventual consistency
await Task.Delay(100);
// Assert
- var messages = await store.GetMessagesAsync();
+ var invokingContext = new ChatMessageStore.InvokingContext([]);
+ var messages = await store.InvokingAsync(invokingContext);
var messageList = messages.ToList();
// Simple assertion - if this fails, we know the deserialization is the issue
@@ -256,7 +262,7 @@ public async Task AddMessagesAsync_WithSingleMessage_ShouldAddMessageAsync()
}
string rawJson = rawResults.Count > 0 ? Newtonsoft.Json.JsonConvert.SerializeObject(rawResults[0], Newtonsoft.Json.Formatting.Indented) : "null";
- Assert.Fail($"GetMessagesAsync returned 0 messages, but direct count query found {count} items for conversation {conversationId}. Raw document: {rawJson}");
+ Assert.Fail($"InvokingAsync returned 0 messages, but direct count query found {count} items for conversation {conversationId}. Raw document: {rawJson}");
}
Assert.Single(messageList);
@@ -266,45 +272,63 @@ public async Task AddMessagesAsync_WithSingleMessage_ShouldAddMessageAsync()
[SkippableFact]
[Trait("Category", "CosmosDB")]
- public async Task AddMessagesAsync_WithMultipleMessages_ShouldAddAllMessagesAsync()
+ public async Task InvokedAsync_WithMultipleMessages_ShouldAddAllMessagesAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
var conversationId = Guid.NewGuid().ToString();
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
- var messages = new[]
+ var requestMessages = new[]
{
new ChatMessage(ChatRole.User, "First message"),
new ChatMessage(ChatRole.Assistant, "Second message"),
new ChatMessage(ChatRole.User, "Third message")
};
+ var aiContextProviderMessages = new[]
+ {
+ new ChatMessage(ChatRole.System, "System context message")
+ };
+ var responseMessages = new[]
+ {
+ new ChatMessage(ChatRole.Assistant, "Response message")
+ };
+
+ var context = new ChatMessageStore.InvokedContext(requestMessages, [])
+ {
+ AIContextProviderMessages = aiContextProviderMessages,
+ ResponseMessages = responseMessages
+ };
// Act
- await store.AddMessagesAsync(messages);
+ await store.InvokedAsync(context);
// Assert
- var retrievedMessages = await store.GetMessagesAsync();
+ var invokingContext = new ChatMessageStore.InvokingContext([]);
+ var retrievedMessages = await store.InvokingAsync(invokingContext);
var messageList = retrievedMessages.ToList();
- Assert.Equal(3, messageList.Count);
+ Assert.Equal(5, messageList.Count);
Assert.Equal("First message", messageList[0].Text);
Assert.Equal("Second message", messageList[1].Text);
Assert.Equal("Third message", messageList[2].Text);
+ Assert.Equal("System context message", messageList[3].Text);
+ Assert.Equal("Response message", messageList[4].Text);
}
#endregion
- #region GetMessagesAsync Tests
+ #region InvokingAsync Tests
[SkippableFact]
[Trait("Category", "CosmosDB")]
- public async Task GetMessagesAsync_WithNoMessages_ShouldReturnEmptyAsync()
+ public async Task InvokingAsync_WithNoMessages_ShouldReturnEmptyAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString());
// Act
- var messages = await store.GetMessagesAsync();
+ var invokingContext = new ChatMessageStore.InvokingContext([]);
+ var messages = await store.InvokingAsync(invokingContext);
// Assert
Assert.Empty(messages);
@@ -312,7 +336,7 @@ public async Task GetMessagesAsync_WithNoMessages_ShouldReturnEmptyAsync()
[SkippableFact]
[Trait("Category", "CosmosDB")]
- public async Task GetMessagesAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync()
+ public async Task InvokingAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
@@ -322,12 +346,18 @@ public async Task GetMessagesAsync_WithConversationIsolation_ShouldOnlyReturnMes
using var store1 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversation1);
using var store2 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversation2);
- await store1.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message for conversation 1")]);
- await store2.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message for conversation 2")]);
+ var context1 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message for conversation 1")], []);
+ var context2 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message for conversation 2")], []);
+
+ await store1.InvokedAsync(context1);
+ await store2.InvokedAsync(context2);
// Act
- var messages1 = await store1.GetMessagesAsync();
- var messages2 = await store2.GetMessagesAsync();
+ var invokingContext1 = new ChatMessageStore.InvokingContext([]);
+ var invokingContext2 = new ChatMessageStore.InvokingContext([]);
+
+ var messages1 = await store1.InvokingAsync(invokingContext1);
+ var messages2 = await store2.InvokingAsync(invokingContext2);
// Assert
var messageList1 = messages1.ToList();
@@ -361,16 +391,18 @@ public async Task FullWorkflow_AddAndGet_ShouldWorkCorrectlyAsync()
};
// Act 1: Add messages
- await originalStore.AddMessagesAsync(messages);
+ var invokedContext = new ChatMessageStore.InvokedContext(messages, []);
+ await originalStore.InvokedAsync(invokedContext);
// Act 2: Verify messages were added
- var retrievedMessages = await originalStore.GetMessagesAsync();
+ var invokingContext = new ChatMessageStore.InvokingContext([]);
+ var retrievedMessages = await originalStore.InvokingAsync(invokingContext);
var retrievedList = retrievedMessages.ToList();
Assert.Equal(5, retrievedList.Count);
// Act 3: Create new store instance for same conversation (test persistence)
using var newStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
- var persistedMessages = await newStore.GetMessagesAsync();
+ var persistedMessages = await newStore.InvokingAsync(invokingContext);
var persistedList = persistedMessages.ToList();
// Assert final state
@@ -502,7 +534,7 @@ public void Constructor_WithHierarchicalWhitespaceSessionId_ShouldThrowArgumentE
[SkippableFact]
[Trait("Category", "CosmosDB")]
- public async Task AddMessagesAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync()
+ public async Task InvokedAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
@@ -513,14 +545,17 @@ public async Task AddMessagesAsync_WithHierarchicalPartitioning_ShouldAddMessage
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId);
var message = new ChatMessage(ChatRole.User, "Hello from hierarchical partitioning!");
+ var context = new ChatMessageStore.InvokedContext([message], []);
+
// Act
- await store.AddMessagesAsync([message]);
+ await store.InvokedAsync(context);
// Wait a moment for eventual consistency
await Task.Delay(100);
// Assert
- var messages = await store.GetMessagesAsync();
+ var invokingContext = new ChatMessageStore.InvokingContext([]);
+ var messages = await store.InvokingAsync(invokingContext);
var messageList = messages.ToList();
Assert.Single(messageList);
@@ -551,7 +586,7 @@ public async Task AddMessagesAsync_WithHierarchicalPartitioning_ShouldAddMessage
[SkippableFact]
[Trait("Category", "CosmosDB")]
- public async Task AddMessagesAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync()
+ public async Task InvokedAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
@@ -567,14 +602,17 @@ public async Task AddMessagesAsync_WithHierarchicalMultipleMessages_ShouldAddAll
new ChatMessage(ChatRole.User, "Third hierarchical message")
};
+ var context = new ChatMessageStore.InvokedContext(messages, []);
+
// Act
- await store.AddMessagesAsync(messages);
+ await store.InvokedAsync(context);
// Wait a moment for eventual consistency
await Task.Delay(100);
// Assert
- var retrievedMessages = await store.GetMessagesAsync();
+ var invokingContext = new ChatMessageStore.InvokingContext([]);
+ var retrievedMessages = await store.InvokingAsync(invokingContext);
var messageList = retrievedMessages.ToList();
Assert.Equal(3, messageList.Count);
@@ -585,7 +623,7 @@ public async Task AddMessagesAsync_WithHierarchicalMultipleMessages_ShouldAddAll
[SkippableFact]
[Trait("Category", "CosmosDB")]
- public async Task GetMessagesAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync()
+ public async Task InvokingAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
@@ -599,17 +637,23 @@ public async Task GetMessagesAsync_WithHierarchicalPartitionIsolation_ShouldIsol
using var store2 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId2, SessionId);
// Add messages to both stores
- await store1.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message from user 1")]);
- await store2.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message from user 2")]);
+ var context1 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message from user 1")], []);
+ var context2 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message from user 2")], []);
+
+ await store1.InvokedAsync(context1);
+ await store2.InvokedAsync(context2);
// Wait a moment for eventual consistency
await Task.Delay(100);
// Act & Assert
- var messages1 = await store1.GetMessagesAsync();
+ var invokingContext1 = new ChatMessageStore.InvokingContext([]);
+ var invokingContext2 = new ChatMessageStore.InvokingContext([]);
+
+ var messages1 = await store1.InvokingAsync(invokingContext1);
var messageList1 = messages1.ToList();
- var messages2 = await store2.GetMessagesAsync();
+ var messages2 = await store2.InvokingAsync(invokingContext2);
var messageList2 = messages2.ToList();
// With true hierarchical partitioning, each user sees only their own messages
@@ -630,7 +674,9 @@ public async Task SerializeDeserialize_WithHierarchicalPartitioning_ShouldPreser
const string SessionId = "session-serialize";
using var originalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId);
- await originalStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Test serialization message")]);
+
+ var context = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Test serialization message")], []);
+ await originalStore.InvokedAsync(context);
// Act - Serialize the store state
var serializedState = originalStore.Serialize();
@@ -647,7 +693,8 @@ public async Task SerializeDeserialize_WithHierarchicalPartitioning_ShouldPreser
await Task.Delay(100);
// Assert - The deserialized store should have the same functionality
- var messages = await deserializedStore.GetMessagesAsync();
+ var invokingContext = new ChatMessageStore.InvokingContext([]);
+ var messages = await deserializedStore.InvokingAsync(invokingContext);
var messageList = messages.ToList();
Assert.Single(messageList);
@@ -670,17 +717,22 @@ public async Task HierarchicalAndSimplePartitioning_ShouldCoexistAsync()
using var hierarchicalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-coexist", "user-coexist", SessionId);
// Add messages to both
- await simpleStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Simple partitioning message")]);
- await hierarchicalStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Hierarchical partitioning message")]);
+ var simpleContext = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Simple partitioning message")], []);
+ var hierarchicalContext = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Hierarchical partitioning message")], []);
+
+ await simpleStore.InvokedAsync(simpleContext);
+ await hierarchicalStore.InvokedAsync(hierarchicalContext);
// Wait a moment for eventual consistency
await Task.Delay(100);
// Act & Assert
- var simpleMessages = await simpleStore.GetMessagesAsync();
+ var invokingContext = new ChatMessageStore.InvokingContext([]);
+
+ var simpleMessages = await simpleStore.InvokingAsync(invokingContext);
var simpleMessageList = simpleMessages.ToList();
- var hierarchicalMessages = await hierarchicalStore.GetMessagesAsync();
+ var hierarchicalMessages = await hierarchicalStore.InvokingAsync(invokingContext);
var hierarchicalMessageList = hierarchicalMessages.ToList();
// Each should only see its own messages since they use different containers
@@ -707,14 +759,17 @@ public async Task MaxMessagesToRetrieve_ShouldLimitAndReturnMostRecentAsync()
messages.Add(new ChatMessage(ChatRole.User, $"Message {i}"));
await Task.Delay(10); // Small delay to ensure different timestamps
}
- await store.AddMessagesAsync(messages);
+
+ var context = new ChatMessageStore.InvokedContext(messages, []);
+ await store.InvokedAsync(context);
// Wait for eventual consistency
await Task.Delay(100);
// Act - Set max to 5 and retrieve
store.MaxMessagesToRetrieve = 5;
- var retrievedMessages = await store.GetMessagesAsync();
+ var invokingContext = new ChatMessageStore.InvokingContext([]);
+ var retrievedMessages = await store.InvokingAsync(invokingContext);
var messageList = retrievedMessages.ToList();
// Assert - Should get the 5 most recent messages (6-10) in ascending order
@@ -742,13 +797,16 @@ public async Task MaxMessagesToRetrieve_Null_ShouldReturnAllMessagesAsync()
{
messages.Add(new ChatMessage(ChatRole.User, $"Message {i}"));
}
- await store.AddMessagesAsync(messages);
+
+ var context = new ChatMessageStore.InvokedContext(messages, []);
+ await store.InvokedAsync(context);
// Wait for eventual consistency
await Task.Delay(100);
// Act - No limit set (default null)
- var retrievedMessages = await store.GetMessagesAsync();
+ var invokingContext = new ChatMessageStore.InvokingContext([]);
+ var retrievedMessages = await store.InvokingAsync(invokingContext);
var messageList = retrievedMessages.ToList();
// Assert - Should get all 10 messages
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs
new file mode 100644
index 0000000000..0d5b895974
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs
@@ -0,0 +1,218 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using A2A;
+using Microsoft.Extensions.AI;
+using Moq;
+using Moq.Protected;
+
+namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
+
+///
+/// Unit tests for the class.
+///
+public sealed class AIAgentExtensionsTests
+{
+ ///
+ /// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync are null.
+ ///
+ [Fact]
+ public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync()
+ {
+ // Arrange
+ AgentRunOptions? capturedOptions = null;
+ ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
+
+ // Act
+ await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
+ Metadata = null
+ });
+
+ // Assert
+ Assert.Null(capturedOptions);
+ }
+
+ ///
+ /// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values.
+ ///
+ [Fact]
+ public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync()
+ {
+ // Arrange
+ AgentRunOptions? capturedOptions = null;
+ ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
+
+ // Act
+ await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
+ Metadata = new Dictionary
+ {
+ ["key1"] = JsonSerializer.SerializeToElement("value1"),
+ ["key2"] = JsonSerializer.SerializeToElement(42)
+ }
+ });
+
+ // Assert
+ Assert.NotNull(capturedOptions);
+ Assert.NotNull(capturedOptions.AdditionalProperties);
+ Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
+ Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key1"));
+ Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key2"));
+ }
+
+ ///
+ /// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync is null
+ /// because the ToAdditionalProperties extension method returns null for empty dictionaries.
+ ///
+ [Fact]
+ public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsync()
+ {
+ // Arrange
+ AgentRunOptions? capturedOptions = null;
+ ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
+
+ // Act
+ await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
+ Metadata = []
+ });
+
+ // Assert
+ Assert.Null(capturedOptions);
+ }
+
+ ///
+ /// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values.
+ ///
+ [Fact]
+ public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProps = new()
+ {
+ ["responseKey1"] = "responseValue1",
+ ["responseKey2"] = 123
+ };
+ AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
+ {
+ AdditionalProperties = additionalProps
+ };
+ ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+
+ // Act
+ A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ });
+
+ // Assert
+ AgentMessage agentMessage = Assert.IsType(a2aResponse);
+ Assert.NotNull(agentMessage.Metadata);
+ Assert.Equal(2, agentMessage.Metadata.Count);
+ Assert.True(agentMessage.Metadata.ContainsKey("responseKey1"));
+ Assert.True(agentMessage.Metadata.ContainsKey("responseKey2"));
+ Assert.Equal("responseValue1", agentMessage.Metadata["responseKey1"].GetString());
+ Assert.Equal(123, agentMessage.Metadata["responseKey2"].GetInt32());
+ }
+
+ ///
+ /// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null.
+ ///
+ [Fact]
+ public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
+ {
+ // Arrange
+ AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
+ {
+ AdditionalProperties = null
+ };
+ ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+
+ // Act
+ A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ });
+
+ // Assert
+ AgentMessage agentMessage = Assert.IsType(a2aResponse);
+ Assert.Null(agentMessage.Metadata);
+ }
+
+ ///
+ /// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null.
+ ///
+ [Fact]
+ public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
+ {
+ // Arrange
+ AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
+ {
+ AdditionalProperties = []
+ };
+ ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+
+ // Act
+ A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ });
+
+ // Assert
+ AgentMessage agentMessage = Assert.IsType(a2aResponse);
+ Assert.Null(agentMessage.Metadata);
+ }
+
+ private static Mock CreateAgentMock(Action optionsCallback)
+ {
+ Mock agentMock = new() { CallBase = true };
+ agentMock.SetupGet(x => x.Name).Returns("TestAgent");
+ agentMock.Setup(x => x.GetNewThread()).Returns(new TestAgentThread());
+ agentMock
+ .Protected()
+ .Setup>("RunCoreAsync",
+ ItExpr.IsAny>(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .Callback, AgentThread?, AgentRunOptions?, CancellationToken>(
+ (_, _, options, _) => optionsCallback(options))
+ .ReturnsAsync(new AgentRunResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
+
+ return agentMock;
+ }
+
+ private static Mock CreateAgentMockWithResponse(AgentRunResponse response)
+ {
+ Mock agentMock = new() { CallBase = true };
+ agentMock.SetupGet(x => x.Name).Returns("TestAgent");
+ agentMock.Setup(x => x.GetNewThread()).Returns(new TestAgentThread());
+ agentMock
+ .Protected()
+ .Setup>("RunCoreAsync",
+ ItExpr.IsAny>(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(response);
+
+ return agentMock;
+ }
+
+ private static async Task InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams)
+ {
+ Func>? handler = taskManager.OnMessageReceived;
+ Assert.NotNull(handler);
+ return await handler.Invoke(messageSendParams, CancellationToken.None);
+ }
+
+ private sealed class TestAgentThread : AgentThread;
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs
new file mode 100644
index 0000000000..e0c8c4e96b
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/AdditionalPropertiesDictionaryExtensionsTests.cs
@@ -0,0 +1,187 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using Microsoft.Agents.AI.Hosting.A2A.Converters;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters;
+
+///
+/// Unit tests for the class.
+///
+public sealed class AdditionalPropertiesDictionaryExtensionsTests
+{
+ [Fact]
+ public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary? additionalProperties = null;
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = [];
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "stringKey", "stringValue" }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("stringKey"));
+ Assert.Equal("stringValue", result["stringKey"].GetString());
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "numberKey", 42 }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("numberKey"));
+ Assert.Equal(42, result["numberKey"].GetInt32());
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "booleanKey", true }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("booleanKey"));
+ Assert.True(result["booleanKey"].GetBoolean());
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "stringKey", "stringValue" },
+ { "numberKey", 42 },
+ { "booleanKey", true }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(3, result.Count);
+
+ Assert.True(result.ContainsKey("stringKey"));
+ Assert.Equal("stringValue", result["stringKey"].GetString());
+
+ Assert.True(result.ContainsKey("numberKey"));
+ Assert.Equal(42, result["numberKey"].GetInt32());
+
+ Assert.True(result.ContainsKey("booleanKey"));
+ Assert.True(result["booleanKey"].GetBoolean());
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement()
+ {
+ // Arrange
+ int[] arrayValue = [1, 2, 3];
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "arrayKey", arrayValue }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("arrayKey"));
+ Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind);
+ Assert.Equal(3, result["arrayKey"].GetArrayLength());
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "nullKey", null! }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("nullKey"));
+ Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind);
+ }
+
+ [Fact]
+ public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement()
+ {
+ // Arrange
+ JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 });
+ AdditionalPropertiesDictionary additionalProperties = new()
+ {
+ { "jsonElementKey", jsonElement }
+ };
+
+ // Act
+ Dictionary? result = additionalProperties.ToA2AMetadata();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.True(result.ContainsKey("jsonElementKey"));
+ Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind);
+ Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString());
+ Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32());
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentContinuationTokenTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentContinuationTokenTests.cs
new file mode 100644
index 0000000000..a2add9634b
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentContinuationTokenTests.cs
@@ -0,0 +1,128 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.UnitTests.ChatClient;
+
+public class ChatClientAgentContinuationTokenTests
+{
+ [Fact]
+ public void ToBytes_Roundtrip()
+ {
+ // Arrange
+ ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
+
+ ChatClientAgentContinuationToken chatClientToken = new(originalToken)
+ {
+ InputMessages =
+ [
+ new ChatMessage(ChatRole.User, "Hello!"),
+ new ChatMessage(ChatRole.User, "How are you?")
+ ],
+ ResponseUpdates =
+ [
+ new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
+ new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
+ ]
+ };
+
+ // Act
+ ReadOnlyMemory bytes = chatClientToken.ToBytes();
+
+ ChatClientAgentContinuationToken tokenFromBytes = ChatClientAgentContinuationToken.FromToken(ResponseContinuationToken.FromBytes(bytes));
+
+ // Assert
+ Assert.NotNull(tokenFromBytes);
+ Assert.Equal(chatClientToken.ToBytes().ToArray(), tokenFromBytes.ToBytes().ToArray());
+
+ // Verify InnerToken
+ Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), tokenFromBytes.InnerToken.ToBytes().ToArray());
+
+ // Verify InputMessages
+ Assert.NotNull(tokenFromBytes.InputMessages);
+ Assert.Equal(chatClientToken.InputMessages.Count(), tokenFromBytes.InputMessages.Count());
+ for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
+ {
+ Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, tokenFromBytes.InputMessages.ElementAt(i).Role);
+ Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, tokenFromBytes.InputMessages.ElementAt(i).Text);
+ }
+
+ // Verify ResponseUpdates
+ Assert.NotNull(tokenFromBytes.ResponseUpdates);
+ Assert.Equal(chatClientToken.ResponseUpdates.Count, tokenFromBytes.ResponseUpdates.Count);
+ for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
+ {
+ Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, tokenFromBytes.ResponseUpdates.ElementAt(i).Role);
+ Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, tokenFromBytes.ResponseUpdates.ElementAt(i).Text);
+ }
+ }
+
+ [Fact]
+ public void Serialization_Roundtrip()
+ {
+ // Arrange
+ ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
+
+ ChatClientAgentContinuationToken chatClientToken = new(originalToken)
+ {
+ InputMessages =
+ [
+ new ChatMessage(ChatRole.User, "Hello!"),
+ new ChatMessage(ChatRole.User, "How are you?")
+ ],
+ ResponseUpdates =
+ [
+ new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
+ new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
+ ]
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(chatClientToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
+
+ ResponseContinuationToken? deserializedToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(json, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
+
+ ChatClientAgentContinuationToken deserializedChatClientToken = ChatClientAgentContinuationToken.FromToken(deserializedToken!);
+
+ // Assert
+ Assert.NotNull(deserializedChatClientToken);
+ Assert.Equal(chatClientToken.ToBytes().ToArray(), deserializedChatClientToken.ToBytes().ToArray());
+
+ // Verify InnerToken
+ Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), deserializedChatClientToken.InnerToken.ToBytes().ToArray());
+
+ // Verify InputMessages
+ Assert.NotNull(deserializedChatClientToken.InputMessages);
+ Assert.Equal(chatClientToken.InputMessages.Count(), deserializedChatClientToken.InputMessages.Count());
+ for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
+ {
+ Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, deserializedChatClientToken.InputMessages.ElementAt(i).Role);
+ Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, deserializedChatClientToken.InputMessages.ElementAt(i).Text);
+ }
+
+ // Verify ResponseUpdates
+ Assert.NotNull(deserializedChatClientToken.ResponseUpdates);
+ Assert.Equal(chatClientToken.ResponseUpdates.Count, deserializedChatClientToken.ResponseUpdates.Count);
+ for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
+ {
+ Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Role);
+ Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Text);
+ }
+ }
+
+ [Fact]
+ public void FromToken_WithChatClientAgentContinuationToken_ReturnsSameInstance()
+ {
+ // Arrange
+ ChatClientAgentContinuationToken originalToken = new(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 }));
+
+ // Act
+ ChatClientAgentContinuationToken fromToken = ChatClientAgentContinuationToken.FromToken(originalToken);
+
+ // Assert
+ Assert.Same(originalToken, fromToken);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs
index 6e9d952b57..29d3d3afee 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs
@@ -502,6 +502,12 @@ public async Task RunAsyncUsesChatMessageStoreFactoryWhenProvidedAndNoConversati
It.IsAny