Compare commits

...
Author SHA1 Message Date
Evan MattsonandGitHub e9d97ce6b7 Python: fix(azure-ai): Fix response_format handling for structured outputs (#3114)
* fix(azure-ai): read response_format from chat_options instead of run_options

* refactor: use explicit None checks for response_format

* Fix mypy error

* Mypy fix
2026-01-07 23:11:28 +00:00
Gavin AguiarandGitHub f4ab586f11 Python: Streaming sample for azurefunctions (#3057)
* Streaming sample for azurefunctions

* Fixed links and sample name

* Addressed feedback

* Addressed feedback

* Fixed integration tests

* Updated test
2026-01-07 22:20:42 +00:00
Eduard van ValkenburgandGitHub a118fd5c07 updated templates (#3106)
* updated templates

* enabled blank and fixed triage

* made language optional and moved to the bottom for features
2026-01-07 15:39:31 +00:00
Mark WallaceandGitHub 521f04632d Enable blank issues in issue template configuration
Need to re-enable creating blank issues
2026-01-07 14:55:43 +00:00
dd69cabc67 .NET: Seal factory contexts and add non JSO deserialize overloads (#3066)
* Seal factory contexts and add non JSO deserialize overloads

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-07 11:40:39 +00:00
Victor DibiaandGitHub 2e1189ca65 Python: Improve DevUI, add Context Inspector view as new tab under traces (#2742)
* Improve DevUI, add Context Inspector view as new tab under traces

* fix mypy errors

* fix: Handle stale MCP connections in DevUI executor

MCP tools can become stale when HTTP streaming responses end - the underlying
stdio streams close but `is_connected` remains True. This causes subsequent
requests to fail with `ClosedResourceError`.

Add `_ensure_mcp_connections()` to detect and reconnect stale MCP tools before
agent execution. This is a workaround for an upstream Agent Framework issue
where connection state isn't properly tracked.

Fixes MCP tools failing on second HTTP request in DevUI.

fixes  #1476 #1515 #2865

* fix #1572 report import dependency errors more clearly

* Ensure there is streaming toggle where users can select streaming vs non streaming mode in devui . Fixes .NET: [Python] DevUI tool call rendering in non-streaming mode?

* remove unused dead code

* improve ux - workflows with agents show a chat component in execution timelien, also ensure magentic final output shows correctly

* update ui build

* update devui to use instrumentation instead of tracing, other instrumentation and type/instance check fixes
2026-01-07 08:26:08 +00:00
claude89757andGitHub db283cd396 Python: Fix MCP tool result serialization for list[TextContent] (#2523)
* Fix MCP tool result serialization for list[TextContent]

When MCP tools return results containing list[TextContent], they were
incorrectly serialized to object repr strings like:
'[<agent_framework._types.TextContent object at 0x...>]'

This fix properly extracts text content from list items by:
1. Checking if items have a 'text' attribute (TextContent)
2. Using model_dump() for items that support it
3. Falling back to str() for other types
4. Joining single items as plain text, multiple items as JSON array

Fixes #2509

* Address PR review feedback for MCP tool result serialization

- Extract serialize_content_result() to shared _utils.py
- Fix logic: use texts[0] instead of join for single item
- Add type annotation: texts: list[str] = []
- Return empty string for empty list instead of '[]'
- Move import json to file top level
- Add comprehensive unit tests for serialization

* Address PR review feedback: fix type checking and double serialization

- Add isinstance(item.text, str) check to ensure text attribute is a string
- Fix double-serialization issue by keeping model_dump results as dicts
  until final json.dumps (removes escaped JSON strings in arrays)
- Improve docstring with detailed return value documentation
- Add test for non-string text attribute handling
- Add tests for list type tool results in _events.py path

* Simplify PR: minimal changes to fix MCP tool result serialization

Addresses reviewer feedback about excessive refactoring:
- Reset _events.py to original structure
- Only add import and use serialize_content_result in one location
- All review comments addressed in serialize_content_result():
  - Added isinstance(item.text, str) check
  - Use model_dump(mode="json") to avoid double-serialization
  - Improved docstring with explicit return value documentation
  - Empty list returns "" instead of "[]"

* Refactor: Move MCP TextContent serialization to core prepare_function_call_results

Per reviewer feedback, moved the TextContent serialization logic from
ag-ui's serialize_content_result to the core package's
prepare_function_call_results function.

Changes:
- Added handling for objects with 'text' attribute (like MCP TextContent)
  in _prepare_function_call_results_as_dumpable
- Removed serialize_content_result from ag-ui/_utils.py
- Updated _events.py and _message_adapters.py to use
  prepare_function_call_results from core package
- Updated tests to match the core function's behavior

* Fix failing tests for prepare_function_call_results behavior

- test_tool_result_with_none: Update expected value to 'null' (JSON serialization of None)
- test_tool_result_with_model_dump_objects: Use Pydantic BaseModel instead of plain class

* Fix B903 linter error: Convert MockTextContent to dataclass

The ruff linter was reporting B903 (class could be dataclass or namedtuple)
for the MockTextContent test helper classes. This commit converts them to
dataclasses to satisfy the linter check.
2026-01-07 00:47:26 +00:00
Evan MattsonandGitHub f49e537721 Bump Bedrock version to latest (#3110) 2026-01-07 09:34:02 +09:00
Evan MattsonandGitHub 202f557c71 Bump versions to 1.0.0b260106 for a release. Update CHANGELOG.md (#3109) 2026-01-07 00:09:49 +00:00
Giles OdigweandGitHub ea370f8ff6 sharepoint sample fix (#3108) 2026-01-06 22:57:54 +00:00
Evan MattsonandGitHub 24c822590f fix: tool_choice parameter not being honored when passed to agent.run() (#3095) 2026-01-06 22:51:20 +00:00
CopilotGitHubwestey-mcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Mark WallaceChris
953fde69ac .NET: Fix message ordering inconsistency when using AIContextProvider (#2659)
* Initial plan

* Fix message ordering inconsistency when using AIContextProvider

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

* Revert to original message ordering: Input, AIContextProvider, Response

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

* Reorder messages to ChatClient to match MessageStore order: Existing, Input, AIContextProvider

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

* Remove redundant test methods as existing tests already verify the behavior

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2026-01-06 15:39:42 +00:00
westeyandGitHub 7a05849609 Fix broken strands urls. (#3102)
* Fix broken strands urls.

* Fix typos
2026-01-06 14:55:29 +00:00
CopilotGitHubSergeyMenshykhcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
0aa0579b1b .NET: Seal ChatClientAgentThread (#2842)
* Initial plan

* Seal ChatClientAgentThread class

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
2026-01-06 10:44:13 +00:00
Evan MattsonandGitHub 844d345106 Python: Fix ExecutorInvokedEvent and ExecutorCompletedEvent observability data (#3090)
* Fix ExecutorInvokedEvent.data mutation bug

* Fix bug related to not yielding output type
2026-01-06 09:12:26 +00:00
takanori-teraiandGitHub ed5278c41d Fix: Update OTLP exporter protocol conditions (#3070) 2026-01-06 04:45:29 +00:00
Evan MattsonandGitHub 928c9d54ad Python: Fix AzureAIClient failure when conversation history contains assistant messages (#3076)
* Fix AzureAIClient failure when conversation history contains assistant messages

* Address PR review feedback: improve docstring and test assertions

* Remove redundant cast
2026-01-05 22:05:46 +00:00
westeyandGitHub 0aba02c402 [BREAKING] Remove unused AgentThreadMetadata (#3067)
* Remove unused AgentThreadMetadata

* Update DurableTask Changelog
2026-01-05 14:03:18 +00:00
3ef67eff10 .NET: [BREAKING] Refactor ChatMessageStore methods to be similar to AIContextProvider and add filtering support (#2604)
* Refactor ChatMessageStore methods to be similar to AIContextProvider

* Fix file encoding

* Ensure that AIContextProvider messages area also persisted.

* Update formatting and seal context classes

* Improve formatting

* Remove optional messages from constructor and add unit test

* Add ChatMessageStore filtering via a decorator

* Update sample and cosmos message store to store AIContextProvider messages in right order. Fix unit tests.

* Update Workflowmessage store to use aicontext provider messages.

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Improve xml docs messaging

* Address code review comments.

* Also notify message store on failure

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
2026-01-05 11:51:15 +00:00
Eduard van ValkenburgandGitHub deea844bc7 fix and extra int test (#3037) 2026-01-05 04:35:10 +00:00
Eduard van ValkenburgandGitHub 577ad4b838 add issue template and additional labeling (#3006) 2026-01-05 01:32:33 +00:00
CopilotGitHubSergeyMenshykhcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>SergeyMenshykhChris
8b4f7d5e29 .NET: [Breaking] Introduce RunCoreAsync/RunCoreStreamingAsync delegation pattern in AIAgent (#2749)
* Initial plan

* Refactor AIAgent: Make RunAsync and RunStreamingAsync non-abstract, add RunCoreAsync and RunCoreStreamingAsync

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Fix infinite recursion in test implementations

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Make RunAsync and RunStreamingAsync non-virtual as requested

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Fix DelegatingAIAgent subclasses to use RunCoreAsync/RunCoreStreamingAsync

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Fix XML documentation references in AnonymousDelegatingAIAgent

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Restore <see cref> tags with proper qualified signatures in AnonymousDelegatingAIAgent

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Rollback unnecessary XML documentation changes in AnonymousDelegatingAIAgent

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Remove pragma and update crefs to RunCoreAsync/RunCoreStreamingAsync

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Fix EntityAgentWrapper to call base.RunCoreAsync/RunCoreStreamingAsync

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* fix compilation issues

* fix compilatio issue

* fix tests

* fix unit tests

* fix unit test

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <sergemenshikh@gmail.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-12-30 12:24:09 +00:00
171 changed files with 11560 additions and 3267 deletions
+8
View File
@@ -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.
+70
View File
@@ -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
@@ -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
+70
View File
@@ -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
+50 -11
View File
@@ -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)
+4 -4
View File
@@ -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). |
@@ -19,12 +19,12 @@ internal sealed class AgenticUIAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -20,12 +20,12 @@ internal sealed class PredictiveStateUpdatesAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -19,12 +19,12 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -96,16 +96,15 @@ public sealed class FunctionTriggers
// Create a new agent thread
AgentThread thread = agentProxy.GetNewThread();
AgentThreadMetadata metadata = thread.GetService<AgentThreadMetadata>()
?? throw new InvalidOperationException("Failed to get AgentThreadMetadata from new thread.");
string agentSessionId = thread.GetService<AgentSessionId>().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 sealed class FunctionTriggers
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);
}
/// <summary>
@@ -65,11 +65,10 @@ public sealed class RedisStreamResponseHandler : IAgentResponseHandler
"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<AgentThreadMetadata>()?.ConversationId
?? throw new InvalidOperationException("Unable to determine conversation ID from the current thread.");
string streamKey = GetStreamKey(conversationId);
string agentSessionId = context.CurrentThread.GetService<AgentSessionId>().ToString();
string streamKey = GetStreamKey(agentSessionId);
IDatabase db = this._redis.GetDatabase();
int sequenceNumber = 0;
@@ -22,17 +22,17 @@ internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken)
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -22,17 +22,17 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken)
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -35,18 +35,18 @@ internal sealed class StatefulAgent<TState> : DelegatingAIAgent
}
/// <inheritdoc />
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken)
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken);
}
/// <inheritdoc />
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -17,17 +17,17 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken)
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -34,7 +34,7 @@ namespace SampleApp
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new CustomAgentThread(serializedThread, jsonSerializerOptions);
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
// Create a thread if the user didn't supply one.
thread ??= this.GetNewThread();
@@ -44,11 +44,19 @@ namespace SampleApp
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<ChatMessage> 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
{
@@ -58,7 +66,7 @@ namespace SampleApp
};
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Create a thread if the user didn't supply one.
thread ??= this.GetNewThread();
@@ -68,11 +76,19 @@ namespace SampleApp
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<ChatMessage> 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)
{
@@ -87,10 +87,10 @@ public class OpenAIChatClientAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public sealed override Task<AgentRunResponse> RunAsync(IEnumerable<Microsoft.Extensions.AI.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunAsync(messages, thread, options, cancellationToken);
protected sealed override Task<AgentRunResponse> RunCoreAsync(IEnumerable<Microsoft.Extensions.AI.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunCoreAsync(messages, thread, options, cancellationToken);
/// <inheritdoc/>
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<Microsoft.Extensions.AI.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunStreamingAsync(messages, thread, options, cancellationToken);
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<Microsoft.Extensions.AI.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunCoreStreamingAsync(messages, thread, options, cancellationToken);
}
@@ -105,10 +105,10 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public sealed override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunAsync(messages, thread, options, cancellationToken);
protected sealed override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunCoreAsync(messages, thread, options, cancellationToken);
/// <inheritdoc/>
public sealed override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunStreamingAsync(messages, thread, options, cancellationToken);
protected sealed override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunCoreStreamingAsync(messages, thread, options, cancellationToken);
}
@@ -62,7 +62,12 @@ AIAgent agent = azureOpenAIClient
.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();
@@ -89,24 +89,7 @@ namespace SampleApp
public string? ThreadDbKey { get; private set; }
public override async Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
this.ThreadDbKey ??= Guid.NewGuid().ToString("N");
var collection = this._vectorStore.GetCollection<string, ChatHistoryItem>("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<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default)
public override async ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var collection = this._vectorStore.GetCollection<string, ChatHistoryItem>("ChatHistory");
await collection.EnsureCollectionExistsAsync(cancellationToken);
@@ -124,6 +107,33 @@ namespace SampleApp
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<string, ChatHistoryItem>("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);
@@ -48,9 +48,9 @@ public class WeatherForecastAgent : DelegatingAIAgent
{
}
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
var response = await base.RunAsync(messages, thread, options, cancellationToken);
var response = await base.RunCoreAsync(messages, thread, options, cancellationToken);
// If the agent returned a valid structured output response
// we might be able to enhance the response with an adaptive card.
@@ -68,7 +68,7 @@ internal sealed class A2AAgent : AIAgent
=> new A2AAgentThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
@@ -131,7 +131,7 @@ internal sealed class A2AAgent : AIAgent
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
@@ -218,6 +218,35 @@ public abstract class AIAgent
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
/// <remarks>
/// <para>
/// This method delegates to <see cref="RunCoreAsync"/> to perform the actual agent invocation. It handles collections of messages,
/// allowing for complex conversational scenarios including multi-turn interactions, function calls, and
/// context-rich conversations.
/// </para>
/// <para>
/// The messages are processed in the order provided and become part of the conversation history.
/// The agent's response will also be added to <paramref name="thread"/> if one is provided.
/// </para>
/// </remarks>
public Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
this.RunCoreAsync(messages, thread, options, cancellationToken);
/// <summary>
/// Core implementation of the agent invocation logic with a collection of chat messages.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input messages and any response messages generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
/// <remarks>
/// <para>
/// This is the primary invocation method that implementations must override. It handles collections of messages,
/// allowing for complex conversational scenarios including multi-turn interactions, function calls, and
/// context-rich conversations.
@@ -227,7 +256,7 @@ public abstract class AIAgent
/// The agent's response will also be added to <paramref name="thread"/> if one is provided.
/// </para>
/// </remarks>
public abstract Task<AgentRunResponse> RunAsync(
protected abstract Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -312,6 +341,34 @@ public abstract class AIAgent
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
/// <remarks>
/// <para>
/// This method delegates to <see cref="RunCoreStreamingAsync"/> to perform the actual streaming invocation. It provides real-time
/// updates as the agent processes the input and generates its response, enabling more responsive user experiences.
/// </para>
/// <para>
/// Each <see cref="AgentRunResponseUpdate"/> represents a portion of the complete response, allowing consumers
/// to display partial results, implement progressive loading, or provide immediate feedback to users.
/// </para>
/// </remarks>
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
this.RunCoreStreamingAsync(messages, thread, options, cancellationToken);
/// <summary>
/// Core implementation of the agent streaming invocation logic with a collection of chat messages.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input messages and any response updates generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
/// <remarks>
/// <para>
/// This is the primary streaming invocation method that implementations must override. It provides real-time
/// updates as the agent processes the input and generates its response, enabling more responsive user experiences.
/// </para>
@@ -320,7 +377,7 @@ public abstract class AIAgent
/// to display partial results, implement progressive loading, or provide immediate feedback to users.
/// </para>
/// </remarks>
public abstract IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected abstract IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -291,6 +291,15 @@ public class AgentRunResponse
return updates;
}
/// <summary>
/// Deserializes the response text into the given type.
/// </summary>
/// <typeparam name="T">The output type to deserialize into.</typeparam>
/// <returns>The result as the requested type.</returns>
/// <exception cref="InvalidOperationException">The result is not parsable into the requested type.</exception>
public T Deserialize<T>() =>
this.Deserialize<T>(AgentAbstractionsJsonUtilities.DefaultOptions);
/// <summary>
/// Deserializes the response text into the given type using the specified serializer options.
/// </summary>
@@ -311,6 +320,15 @@ public class AgentRunResponse
};
}
/// <summary>
/// Tries to deserialize response text into the given type.
/// </summary>
/// <typeparam name="T">The output type to deserialize into.</typeparam>
/// <param name="structuredOutput">The parsed structured output.</param>
/// <returns><see langword="true" /> if parsing was successful; otherwise, <see langword="false" />.</returns>
public bool TryDeserialize<T>([NotNullWhen(true)] out T? structuredOutput) =>
this.TryDeserialize(AgentAbstractionsJsonUtilities.DefaultOptions, out structuredOutput);
/// <summary>
/// Tries to deserialize response text into the given type using the specified serializer options.
/// </summary>
@@ -68,7 +68,7 @@ public abstract class AgentThread
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AgentThread"/>,
/// including itself or any services it might be wrapping. For example, to access the <see cref="AgentThreadMetadata"/> for the instance,
/// including itself or any services it might be wrapping. For example, to access a <see cref="ChatMessageStore"/> if available for the instance,
/// <see cref="GetService"/> may be used to request it.
/// </remarks>
public virtual object? GetService(Type serviceType, object? serviceKey = null)
@@ -1,29 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides metadata information about an <see cref="AgentThread"/> instance.
/// </summary>
[DebuggerDisplay("ConversationId = {ConversationId}")]
public class AgentThreadMetadata
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentThreadMetadata"/> class.
/// </summary>
/// <param name="conversationId">The unique identifier for the conversation, if available.</param>
public AgentThreadMetadata(string? conversationId)
{
this.ConversationId = conversationId;
}
/// <summary>
/// Gets the unique identifier for the conversation, if available.
/// </summary>
/// <remarks>
/// The meaning of this ID may vary depending on the agent implementation.
/// </remarks>
public string? ConversationId { get; }
}
@@ -32,8 +32,9 @@ namespace Microsoft.Agents.AI;
public abstract class ChatMessageStore
{
/// <summary>
/// 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.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains a collection of <see cref="ChatMessage"/>
@@ -59,20 +60,19 @@ public abstract class ChatMessageStore
/// and context management.
/// </para>
/// </remarks>
public abstract Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default);
public abstract ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default);
/// <summary>
/// Asynchronously adds new messages to the store.
/// Called at the end of the agent invocation to add new messages to the store.
/// </summary>
/// <param name="messages">The collection of chat messages to add to the store.</param>
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous add operation.</returns>
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// 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
/// <see cref="GetMessagesAsync"/> return messages in the correct chronological order.
/// <see cref="InvokingAsync"/> return messages in the correct chronological order.
/// </para>
/// <para>
/// Implementations may perform additional processing during message addition, such as:
@@ -83,8 +83,12 @@ public abstract class ChatMessageStore
/// <item><description>Updating indices or search capabilities</description></item>
/// </list>
/// </para>
/// <para>
/// This method is called regardless of whether the invocation succeeded or failed.
/// To check if the invocation was successful, inspect the <see cref="InvokedContext.InvokeException"/> property.
/// </para>
/// </remarks>
public abstract Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default);
public abstract ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default);
/// <summary>
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
@@ -121,4 +125,100 @@ public abstract class ChatMessageStore
/// </remarks>
public TService? GetService<TService>(object? serviceKey = null)
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
/// <summary>
/// Contains the context information provided to <see cref="InvokingAsync(InvokingContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class InvokingContext
{
/// <summary>
/// Initializes a new instance of the <see cref="InvokingContext"/> class with the specified request messages.
/// </summary>
/// <param name="requestMessages">The new messages to be used by the agent for this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
public InvokingContext(IEnumerable<ChatMessage> requestMessages)
{
this.RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages));
}
/// <summary>
/// Gets the caller provided messages that will be used by the agent for this invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing new messages that were provided by the caller.
/// </value>
public IEnumerable<ChatMessage> RequestMessages { get; }
}
/// <summary>
/// Contains the context information provided to <see cref="InvokedAsync(InvokedContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class InvokedContext
{
/// <summary>
/// Initializes a new instance of the <see cref="InvokedContext"/> class with the specified request messages.
/// </summary>
/// <param name="requestMessages">The caller provided messages that were used by the agent for this invocation.</param>
/// <param name="chatMessageStoreMessages">The messages retrieved from the <see cref="ChatMessageStore"/> for this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
public InvokedContext(IEnumerable<ChatMessage> requestMessages, IEnumerable<ChatMessage> chatMessageStoreMessages)
{
this.RequestMessages = Throw.IfNull(requestMessages);
this.ChatMessageStoreMessages = chatMessageStoreMessages;
}
/// <summary>
/// Gets the caller provided messages that were used by the agent for this invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing new messages that were provided by the caller.
/// This does not include any <see cref="ChatMessageStore"/> supplied messages.
/// </value>
public IEnumerable<ChatMessage> RequestMessages { get; }
/// <summary>
/// Gets the messages retrieved from the <see cref="ChatMessageStore"/> for this invocation, if any.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances that were retrieved from the <see cref="ChatMessageStore"/>,
/// and were used by the agent as part of the invocation.
/// </value>
public IEnumerable<ChatMessage> ChatMessageStoreMessages { get; }
/// <summary>
/// Gets or sets the messages provided by the <see cref="AIContextProvider"/> for this invocation, if any.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances that were provided by the <see cref="AIContextProvider"/>,
/// and were used by the agent as part of the invocation.
/// </value>
public IEnumerable<ChatMessage>? AIContextProviderMessages { get; set; }
/// <summary>
/// Gets the collection of response messages generated during this invocation if the invocation succeeded.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the response,
/// or <see langword="null"/> if the invocation failed or did not produce response messages.
/// </value>
public IEnumerable<ChatMessage>? ResponseMessages { get; set; }
/// <summary>
/// Gets the <see cref="Exception"/> that was thrown during the invocation, if the invocation failed.
/// </summary>
/// <value>
/// The exception that caused the invocation to fail, or <see langword="null"/> if the invocation succeeded.
/// </value>
public Exception? InvokeException { get; set; }
}
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Contains extension methods for the <see cref="ChatMessageStore"/> class.
/// </summary>
public static class ChatMessageStoreExtensions
{
/// <summary>
/// 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.
/// </summary>
/// <param name="store">The store to add the message filter to.</param>
/// <param name="invokingMessagesFilter">An optional filter function to apply to messages produced by the store. If null, no filter is applied at this
/// stage.</param>
/// <param name="invokedMessagesFilter">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.</param>
/// <returns>The <see cref="ChatMessageStore"/> with filtering applied.</returns>
public static ChatMessageStore WithMessageFilters(
this ChatMessageStore store,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? invokingMessagesFilter = null,
Func<ChatMessageStore.InvokedContext, ChatMessageStore.InvokedContext>? invokedMessagesFilter = null)
{
return new ChatMessageStoreMessageFilter(
innerChatMessageStore: store,
invokingMessagesFilter: invokingMessagesFilter,
invokedMessagesFilter: invokedMessagesFilter);
}
/// <summary>
/// Decorates the provided chat message store so that it does not store messages produced by any <see cref="AIContextProvider"/>.
/// </summary>
/// <param name="store">The store to add the message filter to.</param>
/// <returns>A new <see cref="ChatMessageStore"/> instance that filters out <see cref="AIContextProvider"/> messages so they do not get stored.</returns>
public static ChatMessageStore WithAIContextProviderMessageRemoval(this ChatMessageStore store)
{
return new ChatMessageStoreMessageFilter(
innerChatMessageStore: store,
invokedMessagesFilter: (ctx) =>
{
ctx.AIContextProviderMessages = null;
return ctx;
});
}
}
@@ -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;
/// <summary>
/// A <see cref="ChatMessageStore"/> decorator that allows filtering the messages
/// passed into and out of an inner <see cref="ChatMessageStore"/>.
/// </summary>
public sealed class ChatMessageStoreMessageFilter : ChatMessageStore
{
private readonly ChatMessageStore _innerChatMessageStore;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? _invokingMessagesFilter;
private readonly Func<InvokedContext, InvokedContext>? _invokedMessagesFilter;
/// <summary>
/// Initializes a new instance of the <see cref="ChatMessageStoreMessageFilter"/> class.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="innerChatMessageStore">The underlying chat message store to be wrapped. Cannot be null.</param>
/// <param name="invokingMessagesFilter">An optional filter function to apply to messages before they are invoked. If null, no filter is applied at this
/// stage.</param>
/// <param name="invokedMessagesFilter">An optional filter function to apply to the invocation context after messages have been invoked. If null, no
/// filter is applied at this stage.</param>
/// <exception cref="ArgumentNullException">Thrown if innerChatMessageStore is null.</exception>
public ChatMessageStoreMessageFilter(
ChatMessageStore innerChatMessageStore,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? invokingMessagesFilter = null,
Func<InvokedContext, InvokedContext>? 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;
}
/// <inheritdoc />
public override async ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var messages = await this._innerChatMessageStore.InvokingAsync(context, cancellationToken).ConfigureAwait(false);
return this._invokingMessagesFilter != null ? this._invokingMessagesFilter(messages) : messages;
}
/// <inheritdoc />
public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
if (this._invokedMessagesFilter != null)
{
context = this._invokedMessagesFilter(context);
}
return this._innerChatMessageStore.InvokedAsync(context, cancellationToken);
}
/// <inheritdoc />
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
return this._innerChatMessageStore.Serialize(jsonSerializerOptions);
}
}
@@ -81,7 +81,7 @@ public abstract class DelegatingAIAgent : AIAgent
=> this.InnerAgent.DeserializeThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc />
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -89,7 +89,7 @@ public abstract class DelegatingAIAgent : AIAgent
=> this.InnerAgent.RunAsync(messages, thread, options, cancellationToken);
/// <inheritdoc />
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -134,21 +134,10 @@ public sealed class InMemoryChatMessageStore : ChatMessageStore, IList<ChatMessa
}
/// <inheritdoc />
public override async Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
public override async ValueTask<IEnumerable<ChatMessage>> 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)
{
this._messages = (await this.ChatReducer.ReduceAsync(this._messages, cancellationToken).ConfigureAwait(false)).ToList();
}
}
/// <inheritdoc />
public override async Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default)
{
if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
{
this._messages = (await this.ChatReducer.ReduceAsync(this._messages, cancellationToken).ConfigureAwait(false)).ToList();
@@ -157,6 +146,26 @@ public sealed class InMemoryChatMessageStore : ChatMessageStore, IList<ChatMessa
return this._messages;
}
/// <inheritdoc />
public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
if (context.InvokeException is not null)
{
return;
}
// 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();
}
}
/// <inheritdoc />
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
@@ -221,7 +230,7 @@ public sealed class InMemoryChatMessageStore : ChatMessageStore, IList<ChatMessa
{
/// <summary>
/// Trigger the reducer when a new message is added.
/// <see cref="AddMessagesAsync(IEnumerable{ChatMessage}, CancellationToken)"/> will only complete when reducer processing is done.
/// <see cref="InvokedAsync(InvokedContext, CancellationToken)"/> will only complete when reducer processing is done.
/// </summary>
AfterMessageAdded,
@@ -58,7 +58,7 @@ public class CopilotStudioAgent : AIAgent
=> new CopilotStudioAgentThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -96,7 +96,7 @@ public class CopilotStudioAgent : AIAgent
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -287,7 +287,7 @@ public sealed class CosmosChatMessageStore : ChatMessageStore, IDisposable
}
/// <inheritdoc />
public override async Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default)
public override async ValueTask<IEnumerable<ChatMessage>> 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 sealed class CosmosChatMessageStore : ChatMessageStore, IDisposable
}
/// <inheritdoc />
public override async Task AddMessagesAsync(IEnumerable<ChatMessage> 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 sealed class CosmosChatMessageStore : ChatMessageStore, IDisposable
}
#pragma warning restore CA1513
var messageList = messages as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
var messageList = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []).ToList();
if (messageList.Count == 0)
{
return;
@@ -381,7 +384,7 @@ public sealed class CosmosChatMessageStore : ChatMessageStore, IDisposable
/// <summary>
/// Adds multiple messages using transactional batch operations for atomicity.
/// </summary>
private async Task AddMessagesInBatchAsync(IReadOnlyCollection<ChatMessage> messages, CancellationToken cancellationToken)
private async Task AddMessagesInBatchAsync(List<ChatMessage> messages, CancellationToken cancellationToken)
{
var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@@ -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
@@ -63,7 +63,7 @@ public sealed class DurableAIAgent : AIAgent
/// <exception cref="AgentNotRegisteredException">Thrown when the agent has not been registered.</exception>
/// <exception cref="ArgumentException">Thrown when the provided thread is not valid for a durable agent.</exception>
/// <exception cref="NotSupportedException">Thrown when cancellation is requested (cancellation is not supported for durable agents).</exception>
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -128,7 +128,7 @@ public sealed class DurableAIAgent : AIAgent
/// <param name="options">Optional run options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A streaming response enumerable.</returns>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -23,7 +23,7 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
return new DurableAgentThread(AgentSessionId.WithRandomKey(this.Name!));
}
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -70,7 +70,7 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
return await agentRunHandle.ReadAgentResponseAsync(cancellationToken);
}
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -55,12 +55,6 @@ public sealed class DurableAgentThread : AgentThread
/// <inheritdoc/>
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;
@@ -21,13 +21,13 @@ internal sealed class EntityAgentWrapper(
// The ID of the agent is always the entity ID.
protected override string? IdCore => this._entityContext.Id.ToString();
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
AgentRunResponse response = await base.RunAsync(
AgentRunResponse response = await base.RunCoreAsync(
messages,
thread,
this.GetAgentEntityRunOptions(options),
@@ -37,13 +37,13 @@ internal sealed class EntityAgentWrapper(
return response;
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (AgentRunResponseUpdate update in base.RunStreamingAsync(
await foreach (AgentRunResponseUpdate update in base.RunCoreStreamingAsync(
messages,
thread,
this.GetAgentEntityRunOptions(options),
@@ -42,13 +42,13 @@ internal class PurviewAgent : AIAgent, IDisposable
}
/// <inheritdoc/>
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken);
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken).ConfigureAwait(false);
foreach (var update in response.ToAgentRunResponseUpdates())
@@ -66,7 +66,7 @@ internal sealed class WorkflowHostAgent : AIAgent
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, jsonSerializerOptions);
private async ValueTask<WorkflowThread> UpdateThreadAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, CancellationToken cancellationToken = default)
private ValueTask<WorkflowThread> UpdateThreadAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, CancellationToken cancellationToken = default)
{
thread ??= this.GetNewThread();
@@ -75,12 +75,14 @@ internal sealed class WorkflowHostAgent : AIAgent
throw new ArgumentException($"Incompatible thread type: {thread.GetType()} (expecting {typeof(WorkflowThread)})", nameof(thread));
}
await workflowThread.MessageStore.AddMessagesAsync(messages, cancellationToken).ConfigureAwait(false);
return workflowThread;
// For workflow threads, messages are added directly via the internal AddMessages method
// The MessageStore methods are used for agent invocation scenarios
workflowThread.MessageStore.AddMessages(messages);
return new ValueTask<WorkflowThread>(workflowThread);
}
public override async
Task<AgentRunResponse> RunAsync(
protected override async
Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -101,8 +103,8 @@ internal sealed class WorkflowHostAgent : AIAgent
return merger.ComputeMerged(workflowThread.LastResponseId!, this.Id, this.Name);
}
public override async
IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async
IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -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,15 +46,22 @@ internal sealed class WorkflowMessageStore : ChatMessageStore
internal void AddMessages(params IEnumerable<ChatMessage> messages) => this._chatMessages.AddRange(messages);
public override Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
public override ValueTask<IEnumerable<ChatMessage>> 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);
return default;
}
public override Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default) => Task.FromResult<IEnumerable<ChatMessage>>(this._chatMessages.AsReadOnly());
public IEnumerable<ChatMessage> GetFromBookmark()
{
for (int i = this._bookmark; i < this._chatMessages.Count; i++)
@@ -17,18 +17,18 @@ namespace Microsoft.Agents.AI;
/// </remarks>
internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
{
/// <summary>The delegate to use as the implementation of <see cref="RunAsync"/>.</summary>
/// <summary>The delegate to use as the implementation of <see cref="RunCoreAsync"/>.</summary>
private readonly Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, Task<AgentRunResponse>>? _runFunc;
/// <summary>The delegate to use as the implementation of <see cref="RunStreamingAsync"/>.</summary>
/// <summary>The delegate to use as the implementation of <see cref="RunCoreStreamingAsync"/>.</summary>
/// <remarks>
/// When non-<see langword="null"/>, this delegate is used as the implementation of <see cref="RunStreamingAsync"/> and
/// When non-<see langword="null"/>, this delegate is used as the implementation of <see cref="RunCoreStreamingAsync"/> and
/// will be invoked with the same arguments as the method itself.
/// When <see langword="null"/>, <see cref="RunStreamingAsync"/> will delegate directly to the inner agent.
/// When <see langword="null"/>, <see cref="RunCoreStreamingAsync"/> will delegate directly to the inner agent.
/// </remarks>
private readonly Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, IAsyncEnumerable<AgentRunResponseUpdate>>? _runStreamingFunc;
/// <summary>The delegate to use as the implementation of both <see cref="RunAsync"/> and <see cref="RunStreamingAsync"/>.</summary>
/// <summary>The delegate to use as the implementation of both <see cref="RunCoreAsync"/> and <see cref="RunCoreStreamingAsync"/>.</summary>
private readonly Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task>? _sharedFunc;
/// <summary>
@@ -36,7 +36,7 @@ internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
/// </summary>
/// <param name="innerAgent">The inner agent.</param>
/// <param name="sharedFunc">
/// A delegate that provides the implementation for both <see cref="RunAsync"/> and <see cref="RunStreamingAsync"/>.
/// A delegate that provides the implementation for both <see cref="RunCoreAsync"/> and <see cref="RunCoreStreamingAsync"/>.
/// In addition to the arguments for the operation, it's provided with a delegate to the inner agent that should be
/// used to perform the operation on the inner agent. It will handle both the non-streaming and streaming cases.
/// </param>
@@ -61,13 +61,13 @@ internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
/// </summary>
/// <param name="innerAgent">The inner agent.</param>
/// <param name="runFunc">
/// A delegate that provides the implementation for <see cref="RunAsync"/>. When <see langword="null"/>,
/// <paramref name="runStreamingFunc"/> must be non-null, and the implementation of <see cref="RunAsync"/>
/// A delegate that provides the implementation for <see cref="RunCoreAsync"/>. When <see langword="null"/>,
/// <paramref name="runStreamingFunc"/> must be non-null, and the implementation of <see cref="RunCoreAsync"/>
/// will use <paramref name="runStreamingFunc"/> for the implementation.
/// </param>
/// <param name="runStreamingFunc">
/// A delegate that provides the implementation for <see cref="RunStreamingAsync"/>. When <see langword="null"/>,
/// <paramref name="runFunc"/> must be non-null, and the implementation of <see cref="RunStreamingAsync"/>
/// A delegate that provides the implementation for <see cref="RunCoreStreamingAsync"/>. When <see langword="null"/>,
/// <paramref name="runFunc"/> must be non-null, and the implementation of <see cref="RunCoreStreamingAsync"/>
/// will use <paramref name="runFunc"/> for the implementation.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
@@ -85,7 +85,7 @@ internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -132,7 +132,7 @@ internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -149,7 +149,7 @@ public sealed partial class ChatClientAgent : AIAgent
internal ChatOptions? ChatOptions => this._agentOptions?.ChatOptions;
/// <inheritdoc/>
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -193,7 +193,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -201,7 +201,7 @@ public sealed partial class ChatClientAgent : AIAgent
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages) =
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages, IList<ChatMessage>? chatMessageStoreMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
ValidateStreamResumptionAllowed(chatOptions?.ContinuationToken, safeThread);
@@ -225,6 +225,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -239,6 +240,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -260,6 +262,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -272,7 +275,7 @@ public sealed partial class ChatClientAgent : AIAgent
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, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
// Notify the AIContextProvider of all new messages.
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
@@ -379,7 +382,7 @@ public sealed partial class ChatClientAgent : AIAgent
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages) =
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages, IList<ChatMessage>? chatMessageStoreMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
var chatClient = this.ChatClient;
@@ -398,6 +401,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -415,7 +419,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
// Only notify the thread of new messages if the chatResponse was successful to avoid inconsistent message state in the thread.
await NotifyMessageStoreOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
await NotifyMessageStoreOfNewMessagesAsync(safeThread, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
// Notify the AIContextProvider of all new messages.
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
@@ -603,7 +607,14 @@ public sealed partial class ChatClientAgent : AIAgent
/// <param name="runOptions">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A tuple containing the thread, chat options, and thread messages.</returns>
private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List<ChatMessage> InputMessagesForChatClient, IList<ChatMessage>? AIContextProviderMessages)> PrepareThreadAndMessagesAsync(
private async Task
<(
ChatClientAgentThread AgentThread,
ChatOptions? ChatOptions,
List<ChatMessage> InputMessagesForChatClient,
IList<ChatMessage>? AIContextProviderMessages,
IList<ChatMessage>? ChatMessageStoreMessages
)> PrepareThreadAndMessagesAsync(
AgentThread? thread,
IEnumerable<ChatMessage> inputMessages,
AgentRunOptions? runOptions,
@@ -637,6 +648,7 @@ public sealed partial class ChatClientAgent : AIAgent
List<ChatMessage> inputMessagesForChatClient = [];
IList<ChatMessage>? aiContextProviderMessages = null;
IList<ChatMessage>? 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 +656,15 @@ public sealed partial class ChatClientAgent : AIAgent
// 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<ChatMessage> ?? 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 +693,6 @@ public sealed partial class ChatClientAgent : AIAgent
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 +713,7 @@ public sealed partial class ChatClientAgent : AIAgent
chatOptions.ConversationId = typedThread.ConversationId;
}
return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages);
return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages, chatMessageStoreMessages);
}
private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId)
@@ -725,7 +740,13 @@ public sealed partial class ChatClientAgent : AIAgent
}
}
private static Task NotifyMessageStoreOfNewMessagesAsync(ChatClientAgentThread thread, IEnumerable<ChatMessage> newMessages, CancellationToken cancellationToken)
private static Task NotifyMessageStoreOfFailureAsync(
ChatClientAgentThread thread,
Exception ex,
IEnumerable<ChatMessage> requestMessages,
IEnumerable<ChatMessage>? chatMessageStoreMessages,
IEnumerable<ChatMessage>? aiContextProviderMessages,
CancellationToken cancellationToken)
{
var messageStore = thread.MessageStore;
@@ -733,7 +754,38 @@ public sealed partial class ChatClientAgent : AIAgent
// 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 Task NotifyMessageStoreOfNewMessagesAsync(
ChatClientAgentThread thread,
IEnumerable<ChatMessage> requestMessages,
IEnumerable<ChatMessage>? chatMessageStoreMessages,
IEnumerable<ChatMessage>? aiContextProviderMessages,
IEnumerable<ChatMessage> responseMessages,
CancellationToken cancellationToken)
{
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)
{
var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages!)
{
AIContextProviderMessages = aiContextProviderMessages,
ResponseMessages = responseMessages
};
return messageStore.InvokedAsync(invokedContext, cancellationToken).AsTask();
}
return Task.CompletedTask;
@@ -80,7 +80,7 @@ public sealed class ChatClientAgentOptions
/// <summary>
/// Context object passed to the <see cref="AIContextProviderFactory"/> to create a new instance of <see cref="AIContextProvider"/>.
/// </summary>
public class AIContextProviderFactoryContext
public sealed class AIContextProviderFactoryContext
{
/// <summary>
/// Gets or sets the serialized state of the <see cref="AIContextProvider"/>, if any.
@@ -97,7 +97,7 @@ public sealed class ChatClientAgentOptions
/// <summary>
/// Context object passed to the <see cref="ChatMessageStoreFactory"/> to create a new instance of <see cref="ChatMessageStore"/>.
/// </summary>
public class ChatMessageStoreFactoryContext
public sealed class ChatMessageStoreFactoryContext
{
/// <summary>
/// Gets or sets the serialized state of the chat message store, if any.
@@ -40,7 +40,6 @@ public sealed class ChatClientAgentRunResponse<T> : AgentRunResponse<T>
/// </summary>
/// <remarks>
/// If the response did not contain JSON, or if deserialization fails, this property will throw.
/// To avoid exceptions, use <see cref="AgentRunResponse.TryDeserialize{T}"/> instead.
/// </remarks>
public override T Result => this._response.Result;
}
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI;
/// Provides a thread implementation for use with <see cref="ChatClientAgent"/>.
/// </summary>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public class ChatClientAgentThread : AgentThread
public sealed class ChatClientAgentThread : AgentThread
{
private ChatMessageStore? _messageStore;
@@ -171,9 +171,7 @@ public class ChatClientAgentThread : AgentThread
/// <inheritdoc/>
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);
@@ -21,10 +21,10 @@ internal sealed class FunctionInvocationDelegatingAgent : DelegatingAIAgent
this._delegateFunc = delegateFunc;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.RunAsync(messages, thread, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken);
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.RunStreamingAsync(messages, thread, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken);
// Decorate options to add the middleware function
@@ -55,7 +55,7 @@ public sealed partial class LoggingAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
if (this._logger.IsEnabled(LogLevel.Debug))
@@ -72,7 +72,7 @@ public sealed partial class LoggingAgent : DelegatingAIAgent
try
{
AgentRunResponse response = await base.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false);
AgentRunResponse response = await base.RunCoreAsync(messages, thread, options, cancellationToken).ConfigureAwait(false);
if (this._logger.IsEnabled(LogLevel.Debug))
{
@@ -101,7 +101,7 @@ public sealed partial class LoggingAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (this._logger.IsEnabled(LogLevel.Debug))
@@ -119,7 +119,7 @@ public sealed partial class LoggingAgent : DelegatingAIAgent
IAsyncEnumerator<AgentRunResponseUpdate> e;
try
{
e = base.RunStreamingAsync(messages, thread, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
e = base.RunCoreStreamingAsync(messages, thread, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
}
catch (OperationCanceledException)
{
@@ -78,7 +78,7 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
}
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
ChatOptions co = new ForwardedOptions(options, thread, Activity.Current);
@@ -89,7 +89,7 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ChatOptions co = new ForwardedOptions(options, thread, Activity.Current);
@@ -39,7 +39,12 @@ public class AnthropicChatCompletionFixture : IChatClientAgentFixture
{
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<ChatClientAgent> CreateChatClientAgentAsync(
@@ -48,7 +48,12 @@ public class AIProjectClientFixture : IChatClientAgentFixture
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<List<ChatMessage>> GetChatHistoryFromResponsesChainAsync(string conversationId)
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
@@ -33,18 +34,20 @@ public class AIAgentTests
this._agentMock = new Mock<AIAgent> { CallBase = true };
this._agentMock
.Setup(x => x.RunAsync(
It.IsAny<IReadOnlyCollection<ChatMessage>>(),
this._agentThreadMock.Object,
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(this._invokeResponse);
this._agentMock
.Setup(x => x.RunStreamingAsync(
It.IsAny<IReadOnlyCollection<ChatMessage>>(),
this._agentThreadMock.Object,
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
.Protected()
.Setup<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(this._invokeStreamingResponses));
}
@@ -64,13 +67,14 @@ public class AIAgentTests
Assert.Equal(this._invokeResponse, response);
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 0),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<Task<AgentRunResponse>>("RunCoreAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => !messages.Any()),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
/// <summary>
@@ -90,13 +94,14 @@ public class AIAgentTests
Assert.Equal(this._invokeResponse, response);
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First().Text == Message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<Task<AgentRunResponse>>("RunCoreAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First().Text == Message),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
/// <summary>
@@ -116,13 +121,14 @@ public class AIAgentTests
Assert.Equal(this._invokeResponse, response);
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First() == message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<Task<AgentRunResponse>>("RunCoreAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First() == message),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
/// <summary>
@@ -144,13 +150,14 @@ public class AIAgentTests
}
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunStreamingAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 0),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => !messages.Any()),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
/// <summary>
@@ -173,13 +180,14 @@ public class AIAgentTests
}
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunStreamingAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First().Text == Message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First().Text == Message),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
/// <summary>
@@ -202,13 +210,14 @@ public class AIAgentTests
}
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunStreamingAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First() == message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First() == message),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
[Fact]
@@ -375,14 +384,14 @@ public class AIAgentTests
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotImplementedException();
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -214,6 +214,12 @@ public class AgentRunResponseTests
Assert.Equal(100, usageContent.Details.TotalTokenCount);
}
#if NETFRAMEWORK
/// <summary>
/// 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.
/// </summary>
[Fact]
public void ParseAsStructuredOutputSuccess()
{
@@ -221,6 +227,24 @@ public class AgentRunResponseTests
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<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 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<Animal>(TestJsonSerializerContext.Default.Options);
@@ -262,6 +286,12 @@ public class AgentRunResponseTests
Assert.Throws<JsonException>(() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
}
#if NETFRAMEWORK
/// <summary>
/// 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.
/// </summary>
[Fact]
public void TryParseAsStructuredOutputSuccess()
{
@@ -269,6 +299,24 @@ public class AgentRunResponseTests
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);
@@ -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;
/// <summary>
/// Contains tests for the <see cref="ChatMessageStoreMessageFilter"/> class.
/// </summary>
public sealed class ChatMessageStoreMessageFilterTests
{
[Fact]
public void Constructor_WithNullInnerStore_ThrowsArgumentNullException()
{
// Arrange, Act & Assert
Assert.Throws<ArgumentNullException>(() => new ChatMessageStoreMessageFilter(null!));
}
[Fact]
public void Constructor_WithOnlyInnerStore_Throws()
{
// Arrange
var innerStoreMock = new Mock<ChatMessageStore>();
// Act & Assert
Assert.Throws<ArgumentException>(() => new ChatMessageStoreMessageFilter(innerStoreMock.Object));
}
[Fact]
public void Constructor_WithAllParameters_CreatesInstance()
{
// Arrange
var innerStoreMock = new Mock<ChatMessageStore>();
IEnumerable<ChatMessage> InvokingFilter(IEnumerable<ChatMessage> 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<ChatMessageStore>();
var expectedMessages = new List<ChatMessage>
{
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<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
}
[Fact]
public async Task InvokingAsync_WithInvokingFilter_AppliesFilterAsync()
{
// Arrange
var innerStoreMock = new Mock<ChatMessageStore>();
var innerMessages = new List<ChatMessage>
{
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<CancellationToken>()))
.ReturnsAsync(innerMessages);
// Filter to only user messages
IEnumerable<ChatMessage> InvokingFilter(IEnumerable<ChatMessage> 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<CancellationToken>()), Times.Once);
}
[Fact]
public async Task InvokingAsync_WithInvokingFilter_CanModifyMessagesAsync()
{
// Arrange
var innerStoreMock = new Mock<ChatMessageStore>();
var innerMessages = new List<ChatMessage>
{
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<CancellationToken>()))
.ReturnsAsync(innerMessages);
// Filter that transforms messages
IEnumerable<ChatMessage> InvokingFilter(IEnumerable<ChatMessage> 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<ChatMessageStore>();
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var chatMessageStoreMessages = new List<ChatMessage> { new(ChatRole.System, "System") };
var responseMessages = new List<ChatMessage> { new(ChatRole.Assistant, "Response") };
var context = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages)
{
ResponseMessages = responseMessages
};
ChatMessageStore.InvokedContext? capturedContext = null;
innerStoreMock
.Setup(s => s.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()))
.Callback<ChatMessageStore.InvokedContext, CancellationToken>((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<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public void Serialize_DelegatesToInnerStore()
{
// Arrange
var innerStoreMock = new Mock<ChatMessageStore>();
var expectedJson = JsonSerializer.SerializeToElement("data", TestJsonSerializerContext.Default.String);
innerStoreMock
.Setup(s => s.Serialize(It.IsAny<JsonSerializerOptions>()))
.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);
}
}
@@ -78,11 +78,11 @@ public class ChatMessageStoreTests
private sealed class TestChatMessageStore : ChatMessageStore
{
public override Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IEnumerable<ChatMessage>>([]);
public override ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> new(Array.Empty<ChatMessage>());
public override Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
=> default;
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
=> default;
@@ -38,19 +38,21 @@ public class DelegatingAIAgentTests
this._innerAgentMock.Setup(x => x.GetNewThread()).Returns(this._testThread);
this._innerAgentMock
.Setup(x => x.RunAsync(
It.IsAny<IReadOnlyCollection<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(this._testResponse);
this._innerAgentMock
.Setup(x => x.RunStreamingAsync(
It.IsAny<IReadOnlyCollection<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
.Protected()
.Setup<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(this._testStreamingResponses));
this._delegatingAgent = new TestDelegatingAIAgent(this._innerAgentMock.Object);
@@ -159,7 +161,12 @@ public class DelegatingAIAgentTests
var innerAgentMock = new Mock<AIAgent>();
innerAgentMock
.Setup(x => x.RunAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken))
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.Is<IEnumerable<ChatMessage>>(m => m == expectedMessages),
ItExpr.Is<AgentThread?>(t => t == expectedThread),
ItExpr.Is<AgentRunOptions?>(o => o == expectedOptions),
ItExpr.Is<CancellationToken>(ct => ct == expectedCancellationToken))
.Returns(expectedResult.Task);
var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object);
@@ -193,7 +200,12 @@ public class DelegatingAIAgentTests
var innerAgentMock = new Mock<AIAgent>();
innerAgentMock
.Setup(x => x.RunStreamingAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken))
.Protected()
.Setup<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.Is<IEnumerable<ChatMessage>>(m => m == expectedMessages),
ItExpr.Is<AgentThread?>(t => t == expectedThread),
ItExpr.Is<AgentRunOptions?>(o => o == expectedOptions),
ItExpr.Is<CancellationToken>(ct => ct == expectedCancellationToken))
.Returns(ToAsyncEnumerableAsync(expectedResults));
var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object);
@@ -47,34 +47,54 @@ public class InMemoryChatMessageStoreTests
}
[Fact]
public async Task AddMessagesAsyncAddsMessagesAndReturnsNullThreadIdAsync()
public async Task InvokedAsyncAddsMessagesAsync()
{
var store = new InMemoryChatMessageStore();
var messages = new List<ChatMessage>
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "Hello")
};
var responseMessages = new List<ChatMessage>
{
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there!")
};
var messageStoreMessages = new List<ChatMessage>()
{
new(ChatRole.System, "original instructions")
};
var aiContextProviderMessages = new List<ChatMessage>()
{
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 class InMemoryChatMessageStoreTests
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 class InMemoryChatMessageStoreTests
}
[Fact]
public async Task AddMessagesAsyncWithEmptyMessagesDoesNotChangeStoreAsync()
public async Task InvokedAsyncWithEmptyMessagesDoesNotChangeStoreAsync()
{
var store = new InMemoryChatMessageStore();
var messages = new List<ChatMessage>();
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<ArgumentNullException>(() => store.AddMessagesAsync(null!, CancellationToken.None));
await Assert.ThrowsAsync<ArgumentNullException>(() => store.InvokedAsync(null!, CancellationToken.None).AsTask());
}
[Fact]
@@ -498,7 +520,8 @@ public class InMemoryChatMessageStoreTests
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 class InMemoryChatMessageStoreTests
.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<ChatMessage>());
var result = (await store.InvokingAsync(invokingContext, CancellationToken.None)).ToList();
// Assert
Assert.Single(result);
@@ -551,7 +579,8 @@ public class InMemoryChatMessageStoreTests
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 class InMemoryChatMessageStoreTests
};
// Act
var result = (await store.GetMessagesAsync(CancellationToken.None)).ToList();
var invokingContext = new ChatMessageStore.InvokingContext(Array.Empty<ChatMessage>());
var result = (await store.InvokingAsync(invokingContext, CancellationToken.None)).ToList();
// Assert
Assert.Single(result);
@@ -202,11 +202,11 @@ public sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
#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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
}
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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
[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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
[SkippableFact]
[Trait("Category", "CosmosDB")]
public async Task GetMessagesAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync()
public async Task InvokingAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
@@ -322,12 +346,18 @@ public sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
};
// 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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
[SkippableFact]
[Trait("Category", "CosmosDB")]
public async Task AddMessagesAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync()
public async Task InvokedAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
@@ -513,14 +545,17 @@ public sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
[SkippableFact]
[Trait("Category", "CosmosDB")]
public async Task AddMessagesAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync()
public async Task InvokedAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
@@ -567,14 +602,17 @@ public sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
[SkippableFact]
[Trait("Category", "CosmosDB")]
public async Task GetMessagesAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync()
public async Task InvokingAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
@@ -599,17 +637,23 @@ public sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
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 sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
{
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
@@ -76,12 +76,12 @@ public sealed class AggregatorPromptAgentFactoryTests
throw new NotImplementedException();
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
@@ -290,7 +290,7 @@ internal sealed class FakeChatClientAgent : AIAgent
return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions);
}
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -305,7 +305,7 @@ internal sealed class FakeChatClientAgent : AIAgent
return updates.ToAgentRunResponse();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -358,7 +358,7 @@ internal sealed class FakeMultiMessageAgent : AIAgent
return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions);
}
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -373,7 +373,7 @@ internal sealed class FakeMultiMessageAgent : AIAgent
return updates.ToAgentRunResponse();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -303,12 +303,12 @@ internal sealed class FakeForwardedPropsAgent : AIAgent
public JsonElement ReceivedForwardedProperties { get; private set; }
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -342,12 +342,12 @@ internal sealed class FakeStateAgent : AIAgent
{
public override string? Description => "Agent for state testing";
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -430,12 +430,12 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
new TestInMemoryAgentThread(serializedThread, jsonSerializerOptions);
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -519,12 +519,12 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
new TestInMemoryAgentThread(serializedThread, jsonSerializerOptions);
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -17,13 +17,13 @@ internal sealed class TestAgent(string name, string description) : AIAgent
JsonElement serializedThread,
JsonSerializerOptions? jsonSerializerOptions = null) => new DummyAgentThread();
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) => Task.FromResult(new AgentRunResponse([.. messages]));
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -6,6 +6,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Moq.Protected;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
using ChatRole = Microsoft.Extensions.AI.ChatRole;
using OpenAIChatMessage = OpenAI.Chat.ChatMessage;
@@ -76,22 +77,28 @@ public sealed class AIAgentWithOpenAIExtensionsTests
var responseMessage = new ChatMessage(ChatRole.Assistant, [new TextContent(ResponseText)]);
mockAgent
.Setup(a => a.RunAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<AgentThread?>(), It.IsAny<AgentRunOptions?>(), It.IsAny<CancellationToken>()))
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new AgentRunResponse([responseMessage]));
// Act
var result = await mockAgent.Object.RunAsync(openAiMessages, mockThread.Object, options, cancellationToken);
// Assert
mockAgent.Verify(
a => a.RunAsync(
It.Is<IEnumerable<ChatMessage>>(msgs =>
mockAgent.Protected()
.Verify("RunCoreAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(msgs =>
msgs.ToList().Count == 1 &&
msgs.ToList()[0].Text == TestMessageText),
mockThread.Object,
options,
cancellationToken),
Times.Once);
cancellationToken
);
Assert.NotNull(result);
Assert.NotEmpty(result.Content);
@@ -160,7 +167,12 @@ public sealed class AIAgentWithOpenAIExtensionsTests
};
mockAgent
.Setup(a => a.RunStreamingAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<AgentThread?>(), It.IsAny<AgentRunOptions?>(), It.IsAny<CancellationToken>()))
.Protected()
.Setup<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(responseUpdates));
// Act
@@ -172,15 +184,16 @@ public sealed class AIAgentWithOpenAIExtensionsTests
}
// Assert
mockAgent.Verify(
a => a.RunStreamingAsync(
It.Is<IEnumerable<ChatMessage>>(msgs =>
mockAgent.Protected()
.Verify("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(msgs =>
msgs.ToList().Count == 1 &&
msgs.ToList()[0].Text == TestMessageText),
mockThread.Object,
options,
cancellationToken),
Times.Once);
cancellationToken
);
Assert.True(updateCount > 0, "Expected at least one streaming update");
}
@@ -8,6 +8,7 @@ using Microsoft.Agents.AI.Purview.Models.Common;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Purview.UnitTests;
@@ -277,11 +278,13 @@ public sealed class PurviewWrapperTests : IDisposable
Assert.Single(result.Messages);
Assert.Equal(ChatRole.System, result.Messages[0].Role);
Assert.Equal("Prompt blocked by policy", result.Messages[0].Text);
mockAgent.Verify(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
mockAgent.Protected().Verify("RunCoreAsync",
Times.Never(),
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>());
}
[Fact]
@@ -295,11 +298,12 @@ public sealed class PurviewWrapperTests : IDisposable
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(innerResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
@@ -333,11 +337,12 @@ public sealed class PurviewWrapperTests : IDisposable
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Safe response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(innerResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
@@ -375,11 +380,12 @@ public sealed class PurviewWrapperTests : IDisposable
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(expectedResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
@@ -441,11 +447,12 @@ public sealed class PurviewWrapperTests : IDisposable
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(expectedResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
@@ -482,11 +489,12 @@ public sealed class PurviewWrapperTests : IDisposable
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(expectedResponse);
string? capturedThreadId = null;
@@ -521,11 +529,12 @@ public sealed class PurviewWrapperTests : IDisposable
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(innerResponse);
var callCount = 0;
@@ -337,7 +337,7 @@ public class AgentExtensionsTests
public CancellationToken LastCancellationToken { get; private set; }
public int RunAsyncCallCount { get; private set; }
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -355,7 +355,7 @@ public class AgentExtensionsTests
return Task.FromResult(this._responseToReturn!);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
@@ -35,18 +36,22 @@ public class AnonymousDelegatingAIAgentTests
new AgentRunResponseUpdate(ChatRole.Assistant, "Response 2")
];
this._innerAgentMock.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
this._innerAgentMock
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(this._testResponse);
this._innerAgentMock.Setup(x => x.RunStreamingAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
this._innerAgentMock
.Protected()
.Setup<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(this._testStreamingResponses));
}
@@ -184,11 +189,14 @@ public class AnonymousDelegatingAIAgentTests
Assert.Same(this._testOptions, capturedOptions);
Assert.Equal(expectedCancellationToken, capturedCancellationToken);
this._innerAgentMock.Verify(x => x.RunAsync(
this._testMessages,
this._testThread,
this._testOptions,
expectedCancellationToken), Times.Once);
this._innerAgentMock
.Protected()
.Verify<Task<AgentRunResponse>>("RunCoreAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(m => m == this._testMessages),
ItExpr.Is<AgentThread?>(t => t == this._testThread),
ItExpr.Is<AgentRunOptions?>(o => o == this._testOptions),
ItExpr.Is<CancellationToken>(ct => ct == expectedCancellationToken));
}
/// <summary>
@@ -458,11 +466,13 @@ public class AnonymousDelegatingAIAgentTests
capturedValue = asyncLocal.Value;
});
this._innerAgentMock.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
this._innerAgentMock
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(() =>
{
// Verify AsyncLocal value is available in inner agent call
@@ -926,11 +936,13 @@ public class AnonymousDelegatingAIAgentTests
var capturedTokens = new List<CancellationToken>();
// Setup mock to throw OperationCanceledException when cancelled token is used
this._innerAgentMock.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.Is<CancellationToken>(ct => ct.IsCancellationRequested)))
this._innerAgentMock
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.Is<CancellationToken>(ct => ct.IsCancellationRequested))
.ThrowsAsync(new OperationCanceledException());
var agent = new AIAgentBuilder(this._innerAgentMock.Object)
@@ -993,11 +1005,14 @@ public class AnonymousDelegatingAIAgentTests
Assert.Equal(expectedOrder, executionOrder);
// Verify inner agent was never called
this._innerAgentMock.Verify(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()), Times.Never);
this._innerAgentMock
.Protected()
.Verify<Task<AgentRunResponse>>("RunCoreAsync",
Times.Never(),
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>());
}
#endregion
@@ -502,6 +502,12 @@ public partial class ChatClientAgentTests
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock<ChatMessageStore> mockChatMessageStore = new();
mockChatMessageStore.Setup(s => s.InvokingAsync(
It.IsAny<ChatMessageStore.InvokingContext>(),
It.IsAny<CancellationToken>())).ReturnsAsync([new ChatMessage(ChatRole.User, "Existing Chat History")]);
mockChatMessageStore.Setup(s => s.InvokedAsync(
It.IsAny<ChatMessageStore.InvokedContext>(),
It.IsAny<CancellationToken>())).Returns(new ValueTask());
Mock<Func<ChatClientAgentOptions.ChatMessageStoreFactoryContext, ChatMessageStore>> mockFactory = new();
mockFactory.Setup(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>())).Returns(mockChatMessageStore.Object);
@@ -518,7 +524,58 @@ public partial class ChatClientAgentTests
// Assert
Assert.IsType<ChatMessageStore>(thread!.MessageStore, exactMatch: false);
mockChatMessageStore.Verify(s => s.AddMessagesAsync(It.Is<IEnumerable<ChatMessage>>(x => x.Count() == 2), It.IsAny<CancellationToken>()), Times.Once);
mockService.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
mockChatMessageStore.Verify(s => s.InvokingAsync(
It.Is<ChatMessageStore.InvokingContext>(x => x.RequestMessages.Count() == 1),
It.IsAny<CancellationToken>()),
Times.Once);
mockChatMessageStore.Verify(s => s.InvokedAsync(
It.Is<ChatMessageStore.InvokedContext>(x => x.RequestMessages.Count() == 1 && x.ChatMessageStoreMessages.Count() == 1 && x.ResponseMessages!.Count() == 1),
It.IsAny<CancellationToken>()),
Times.Once);
mockFactory.Verify(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>()), Times.Once);
}
/// <summary>
/// Verify that RunAsync notifies the ChatMessageStore on failure.
/// </summary>
[Fact]
public async Task RunAsyncNotifiesChatMessageStoreOnFailureAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Throws(new InvalidOperationException("Test Error"));
Mock<ChatMessageStore> mockChatMessageStore = new();
Mock<Func<ChatClientAgentOptions.ChatMessageStoreFactoryContext, ChatMessageStore>> mockFactory = new();
mockFactory.Setup(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>())).Returns(mockChatMessageStore.Object);
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatMessageStoreFactory = mockFactory.Object
});
// Act
ChatClientAgentThread? thread = agent.GetNewThread() as ChatClientAgentThread;
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], thread));
// Assert
Assert.IsType<ChatMessageStore>(thread!.MessageStore, exactMatch: false);
mockChatMessageStore.Verify(s => s.InvokedAsync(
It.Is<ChatMessageStore.InvokedContext>(x => x.RequestMessages.Count() == 1 && x.ResponseMessages == null && x.InvokeException!.Message == "Test Error"),
It.IsAny<CancellationToken>()),
Times.Once);
mockFactory.Verify(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>()), Times.Once);
}
@@ -599,18 +656,18 @@ public partial class ChatClientAgentTests
await agent.RunAsync(requestMessages, thread);
// Assert
// Should contain: base instructions, context message, user message, base function, context function
// Should contain: base instructions, user message, context message, base function, context function
Assert.Equal(2, capturedMessages.Count);
Assert.Equal("base instructions\ncontext provider instructions", capturedInstructions);
Assert.Equal("context provider message", capturedMessages[0].Text);
Assert.Equal(ChatRole.System, capturedMessages[0].Role);
Assert.Equal("user message", capturedMessages[1].Text);
Assert.Equal(ChatRole.User, capturedMessages[1].Role);
Assert.Equal("user message", capturedMessages[0].Text);
Assert.Equal(ChatRole.User, capturedMessages[0].Role);
Assert.Equal("context provider message", capturedMessages[1].Text);
Assert.Equal(ChatRole.System, capturedMessages[1].Role);
Assert.Equal(2, capturedTools.Count);
Assert.Contains(capturedTools, t => t.Name == "base function");
Assert.Contains(capturedTools, t => t.Name == "context provider function");
// Verify that the thread was updated with the input, ai context and response messages
// Verify that the thread was updated with the ai context provider, input and response messages
var messageStore = Assert.IsType<InMemoryChatMessageStore>(thread!.MessageStore);
Assert.Equal(3, messageStore.Count);
Assert.Equal("user message", messageStore[0].Text);
@@ -2056,18 +2113,18 @@ public partial class ChatClientAgentTests
_ = await updates.ToAgentRunResponseAsync();
// Assert
// Should contain: base instructions, context message, user message, base function, context function
// Should contain: base instructions, user message, context message, base function, context function
Assert.Equal(2, capturedMessages.Count);
Assert.Equal("base instructions\ncontext provider instructions", capturedInstructions);
Assert.Equal("context provider message", capturedMessages[0].Text);
Assert.Equal(ChatRole.System, capturedMessages[0].Role);
Assert.Equal("user message", capturedMessages[1].Text);
Assert.Equal(ChatRole.User, capturedMessages[1].Role);
Assert.Equal("user message", capturedMessages[0].Text);
Assert.Equal(ChatRole.User, capturedMessages[0].Role);
Assert.Equal("context provider message", capturedMessages[1].Text);
Assert.Equal(ChatRole.System, capturedMessages[1].Role);
Assert.Equal(2, capturedTools.Count);
Assert.Contains(capturedTools, t => t.Name == "base function");
Assert.Contains(capturedTools, t => t.Name == "context provider function");
// Verify that the thread was updated with the input, ai context and response messages
// Verify that the thread was updated with the input, ai context provider, and response messages
var messageStore = Assert.IsType<InMemoryChatMessageStore>(thread!.MessageStore);
Assert.Equal(3, messageStore.Count);
Assert.Equal("user message", messageStore[0].Text);
@@ -339,7 +339,7 @@ public class ChatClientAgent_BackgroundResponsesTests
// Create a mock message store that would normally provide messages
var mockMessageStore = new Mock<ChatMessageStore>();
mockMessageStore
.Setup(ms => ms.GetMessagesAsync(It.IsAny<CancellationToken>()))
.Setup(ms => ms.InvokingAsync(It.IsAny<ChatMessageStore.InvokingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new(ChatRole.User, "Message from message store")]);
// Create a mock AI context provider that would normally provide context
@@ -383,7 +383,7 @@ public class ChatClientAgent_BackgroundResponsesTests
// Verify that message store was never called due to continuation token
mockMessageStore.Verify(
ms => ms.GetMessagesAsync(It.IsAny<CancellationToken>()),
ms => ms.InvokingAsync(It.IsAny<ChatMessageStore.InvokingContext>(), It.IsAny<CancellationToken>()),
Times.Never);
// Verify that AI context provider was never called due to continuation token
@@ -401,7 +401,7 @@ public class ChatClientAgent_BackgroundResponsesTests
// Create a mock message store that would normally provide messages
var mockMessageStore = new Mock<ChatMessageStore>();
mockMessageStore
.Setup(ms => ms.GetMessagesAsync(It.IsAny<CancellationToken>()))
.Setup(ms => ms.InvokingAsync(It.IsAny<ChatMessageStore.InvokingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new(ChatRole.User, "Message from message store")]);
// Create a mock AI context provider that would normally provide context
@@ -446,7 +446,7 @@ public class ChatClientAgent_BackgroundResponsesTests
// Verify that message store was never called due to continuation token
mockMessageStore.Verify(
ms => ms.GetMessagesAsync(It.IsAny<CancellationToken>()),
ms => ms.InvokingAsync(It.IsAny<ChatMessageStore.InvokingContext>(), It.IsAny<CancellationToken>()),
Times.Never);
// Verify that AI context provider was never called due to continuation token
@@ -30,10 +30,10 @@ internal sealed class TestAIAgent : AIAgent
public override AgentThread GetNewThread() =>
this.GetNewThreadFunc();
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
this.RunAsyncFunc(messages, thread, options, cancellationToken);
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
this.RunStreamingAsyncFunc(messages, thread, options, cancellationToken);
public override object? GetService(Type serviceType, object? serviceKey = null) =>
@@ -141,11 +141,11 @@ public class AgentWorkflowBuilderTests
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new DoubleEchoAgentThread();
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
@@ -409,7 +409,7 @@ public class AgentWorkflowBuilderTests
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
{
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (Interlocked.Decrement(ref remaining.Value) == 0)
@@ -419,7 +419,7 @@ public class AgentWorkflowBuilderTests
await barrier.Value!.Task.ConfigureAwait(false);
await foreach (var update in base.RunStreamingAsync(messages, thread, options, cancellationToken))
await foreach (var update in base.RunCoreStreamingAsync(messages, thread, options, cancellationToken))
{
await Task.Yield();
yield return update;
@@ -149,7 +149,7 @@ public class InProcessExecutionTests
public override AgentThread DeserializeThread(System.Text.Json.JsonElement serializedThread,
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions = null) => new SimpleTestAgentThread();
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -160,7 +160,7 @@ public class InProcessExecutionTests
return Task.FromResult(new AgentRunResponse(responseMessage));
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -30,10 +30,10 @@ public class RepresentationTests
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotImplementedException();
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
@@ -66,17 +66,17 @@ internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new HelloAgentThread();
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
IEnumerable<AgentRunResponseUpdate> update = [
await this.RunStreamingAsync(messages, thread, options, cancellationToken)
await this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.SingleAsync(cancellationToken)
.ConfigureAwait(false)];
return update.ToAgentRunResponse();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
yield return new(ChatRole.Assistant, "Hello World!")
{
@@ -62,14 +62,14 @@ public class SpecializedExecutorSmokeTests
public List<ChatMessage> Messages { get; } = Validate(messages) ?? [];
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult(new AgentRunResponse(this.Messages)
{
AgentId = this.Id,
ResponseId = Guid.NewGuid().ToString("N")
});
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string responseId = Guid.NewGuid().ToString("N");
foreach (ChatMessage message in this.Messages)
@@ -60,7 +60,7 @@ internal class TestEchoAgent(string? id = null, string? name = null, string? pre
return [];
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
AgentRunResponse result =
new(this.EchoMessages(messages, thread, options).ToList())
@@ -73,7 +73,7 @@ internal class TestEchoAgent(string? id = null, string? name = null, string? pre
return Task.FromResult(result);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string responseId = Guid.NewGuid().ToString("N");
@@ -32,7 +32,12 @@ public class OpenAIChatCompletionFixture : IChatClientAgentFixture
{
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<ChatClientAgent> CreateChatClientAgentAsync(
@@ -50,7 +50,12 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
return [.. previousMessages, responseMessage];
}
return typedThread.MessageStore is null ? [] : (await typedThread.MessageStore.GetMessagesAsync()).ToList();
if (typedThread.MessageStore is null)
{
return [];
}
return (await typedThread.MessageStore.InvokingAsync(new([]))).ToList();
}
private static ChatMessage ConvertToChatMessage(ResponseItem item)
+21 -1
View File
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b260106] - 2026-01-06
### Added
- **repo**: Add issue template and additional labeling ([#3006](https://github.com/microsoft/agent-framework/pull/3006)) by @eavanvalkenburg
### Changed
- None
### Fixed
- **agent-framework-core**: Fix max tokens translation and add extra integer test ([#3037](https://github.com/microsoft/agent-framework/pull/3037)) by @eavanvalkenburg
- **agent-framework-azure-ai**: Fix failure when conversation history contains assistant messages ([#3076](https://github.com/microsoft/agent-framework/pull/3076)) by @moonbox3
- **agent-framework-core**: Use HTTP exporter for http/protobuf protocol ([#3070](https://github.com/microsoft/agent-framework/pull/3070)) by @takanori-terai
- **agent-framework-core**: Fix ExecutorInvokedEvent and ExecutorCompletedEvent observability data ([#3090](https://github.com/microsoft/agent-framework/pull/3090)) by @moonbox3
- **agent-framework-core**: Honor tool_choice parameter passed to agent.run() and chat client methods ([#3095](https://github.com/microsoft/agent-framework/pull/3095)) by @moonbox3
- **samples**: AzureAI SharePoint sample fix ([#3108](https://github.com/microsoft/agent-framework/pull/3108)) by @giles17
## [1.0.0b251223] - 2025-12-23
### Added
@@ -426,7 +445,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251223...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260106...HEAD
[1.0.0b260106]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251223...python-1.0.0b260106
[1.0.0b251223]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251218...python-1.0.0b251223
[1.0.0b251218]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251216...python-1.0.0b251218
[1.0.0b251216]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251211...python-1.0.0b251216
+1 -1
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251223"
version = "1.0.0b260106"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -31,6 +31,7 @@ from agent_framework import (
FunctionCallContent,
FunctionResultContent,
TextContent,
prepare_function_call_results,
)
from ._utils import generate_event_id
@@ -391,12 +392,7 @@ class AgentFrameworkEventBridge:
self.state_delta_count = 0
result_message_id = generate_event_id()
if isinstance(content.result, dict):
result_content = json.dumps(content.result) # type: ignore[arg-type]
elif content.result is not None:
result_content = str(content.result)
else:
result_content = ""
result_content = prepare_function_call_results(content.result)
result_event = ToolCallResultEvent(
message_id=result_message_id,
@@ -2,6 +2,7 @@
"""Message format conversion between AG-UI and Agent Framework."""
import json
from typing import Any, cast
from agent_framework import (
@@ -11,6 +12,7 @@ from agent_framework import (
FunctionResultContent,
Role,
TextContent,
prepare_function_call_results,
)
# Role mapping constants
@@ -59,10 +61,8 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# Distinguish approval payloads from actual tool results
is_approval = False
if isinstance(result_content, str) and result_content:
import json as _json
try:
parsed = _json.loads(result_content)
parsed = json.loads(result_content)
is_approval = isinstance(parsed, dict) and "accepted" in parsed
except Exception:
is_approval = False
@@ -237,13 +237,8 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
elif isinstance(content, FunctionResultContent):
# Tool result content - extract call_id and result
tool_result_call_id = content.call_id
# Serialize result to string
if isinstance(content.result, dict):
import json
content_text = json.dumps(content.result) # type: ignore
elif content.result is not None:
content_text = str(content.result)
# Serialize result to string using core utility
content_text = prepare_function_call_results(content.result)
agui_msg: dict[str, Any] = {
"id": msg.message_id if msg.message_id else generate_event_id(), # Always include id
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b251223"
version = "1.0.0b260106"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -201,7 +201,8 @@ async def test_tool_result_with_none():
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
assert events[1].content == ""
# prepare_function_call_results serializes None as JSON "null"
assert events[1].content == "null"
async def test_multiple_tool_results_in_sequence():
@@ -688,3 +689,97 @@ async def test_state_delta_count_logging():
# State delta count should have incremented (one per unique state update)
assert bridge.state_delta_count >= 1
# Tests for list type tool results (MCP tool serialization)
async def test_tool_result_with_empty_list():
"""Test FunctionResultContent with empty list result."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=[])])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
# Empty list serializes as JSON empty array
assert events[1].content == "[]"
async def test_tool_result_with_single_text_content():
"""Test FunctionResultContent with single TextContent-like item (MCP tool result)."""
from dataclasses import dataclass
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@dataclass
class MockTextContent:
text: str
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(
contents=[FunctionResultContent(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
)
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
# TextContent text is extracted and serialized as JSON array
assert events[1].content == '["Hello from MCP tool!"]'
async def test_tool_result_with_multiple_text_contents():
"""Test FunctionResultContent with multiple TextContent-like items (MCP tool result)."""
from dataclasses import dataclass
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@dataclass
class MockTextContent:
text: str
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(
contents=[
FunctionResultContent(
call_id="call_123",
result=[MockTextContent("First result"), MockTextContent("Second result")],
)
]
)
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
# Multiple TextContent items should return JSON array
assert events[1].content == '["First result", "Second result"]'
async def test_tool_result_with_model_dump_objects():
"""Test FunctionResultContent with Pydantic BaseModel objects."""
from pydantic import BaseModel
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
class MockModel(BaseModel):
value: int
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(
contents=[FunctionResultContent(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
)
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[1].type == "TOOL_CALL_RESULT"
# Should be properly serialized JSON array without double escaping
assert events[1].content == '[{"value": 1}, {"value": 2}]'
@@ -3,7 +3,7 @@
"""Tests for message adapters."""
import pytest
from agent_framework import ChatMessage, FunctionCallContent, Role, TextContent
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, Role, TextContent
from agent_framework_ag_ui._message_adapters import (
agent_framework_messages_to_agui,
@@ -278,3 +278,119 @@ def test_extract_text_from_custom_contents():
result = extract_text_from_contents(contents)
assert result == "Custom Mixed"
# Tests for FunctionResultContent serialization in agent_framework_messages_to_agui
def test_agent_framework_to_agui_function_result_dict():
"""Test converting FunctionResultContent with dict result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result={"key": "value", "count": 42})],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
assert agui_msg["role"] == "tool"
assert agui_msg["toolCallId"] == "call-123"
assert agui_msg["content"] == '{"key": "value", "count": 42}'
def test_agent_framework_to_agui_function_result_none():
"""Test converting FunctionResultContent with None result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=None)],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
# None serializes as JSON null
assert agui_msg["content"] == "null"
def test_agent_framework_to_agui_function_result_string():
"""Test converting FunctionResultContent with string result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result="plain text result")],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
assert agui_msg["content"] == "plain text result"
def test_agent_framework_to_agui_function_result_empty_list():
"""Test converting FunctionResultContent with empty list result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=[])],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
# Empty list serializes as JSON empty array
assert agui_msg["content"] == "[]"
def test_agent_framework_to_agui_function_result_single_text_content():
"""Test converting FunctionResultContent with single TextContent-like item."""
from dataclasses import dataclass
@dataclass
class MockTextContent:
text: str
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
# TextContent text is extracted and serialized as JSON array
assert agui_msg["content"] == '["Hello from MCP!"]'
def test_agent_framework_to_agui_function_result_multiple_text_contents():
"""Test converting FunctionResultContent with multiple TextContent-like items."""
from dataclasses import dataclass
@dataclass
class MockTextContent:
text: str
msg = ChatMessage(
role=Role.TOOL,
contents=[
FunctionResultContent(
call_id="call-123",
result=[MockTextContent("First result"), MockTextContent("Second result")],
)
],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
# Multiple items should return JSON array
assert agui_msg["content"] == '["First result", "Second result"]'
+5 -1
View File
@@ -5,7 +5,11 @@
from dataclasses import dataclass
from datetime import date, datetime
from agent_framework_ag_ui._utils import generate_event_id, make_json_safe, merge_state
from agent_framework_ag_ui._utils import (
generate_event_id,
make_json_safe,
merge_state,
)
def test_generate_event_id():
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251223"
version = "1.0.0b260106"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251223"
version = "1.0.0b260106"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -2,7 +2,7 @@
import sys
from collections.abc import Mapping, MutableSequence
from typing import Any, ClassVar, TypeVar
from typing import Any, ClassVar, TypeVar, cast
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
@@ -300,13 +300,26 @@ class AzureAIClient(OpenAIBaseResponsesClient):
raise ServiceInvalidRequestError("response_format must be a Pydantic model or mapping.")
async def _get_agent_reference_or_create(
self, run_options: dict[str, Any], messages_instructions: str | None
self,
run_options: dict[str, Any],
messages_instructions: str | None,
chat_options: ChatOptions | None = None,
) -> dict[str, str]:
"""Determine which agent to use and create if needed.
Args:
run_options: The prepared options for the API call.
messages_instructions: Instructions extracted from messages.
chat_options: The chat options containing response_format and other settings.
Returns:
dict[str, str]: The agent reference to use.
"""
# chat_options is needed separately because the base class excludes response_format
# from run_options (transforming it to text/text_format for OpenAI). Azure's agent
# creation API requires the original response_format to build its own config format.
if chat_options is None:
chat_options = ChatOptions()
# Agent name must be explicitly provided by the user.
if self.agent_name is None:
raise ServiceInitializationError(
@@ -341,8 +354,14 @@ class AzureAIClient(OpenAIBaseResponsesClient):
if "top_p" in run_options:
args["top_p"] = run_options["top_p"]
if "response_format" in run_options:
response_format = run_options["response_format"]
# response_format is accessed from chat_options or additional_properties
# since the base class excludes it from run_options
response_format: Any = (
chat_options.response_format
if chat_options.response_format is not None
else chat_options.additional_properties.get("response_format")
)
if response_format:
args["text"] = PromptAgentDefinitionText(format=self._create_text_format_config(response_format))
# Combine instructions from messages and options
@@ -379,20 +398,67 @@ class AzureAIClient(OpenAIBaseResponsesClient):
"""Take ChatOptions and create the specific options for Azure AI."""
prepared_messages, instructions = self._prepare_messages_for_azure_ai(messages)
run_options = await super()._prepare_options(prepared_messages, chat_options, **kwargs)
# WORKAROUND: Azure AI Projects 'create responses' API has schema divergence from OpenAI's
# Responses API. Azure requires 'type' at item level and 'annotations' in content items.
# See: https://github.com/Azure/azure-sdk-for-python/issues/44493
# See: https://github.com/microsoft/agent-framework/issues/2926
# TODO(agent-framework#2926): Remove this workaround when Azure SDK aligns with OpenAI schema.
if "input" in run_options and isinstance(run_options["input"], list):
run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"]))
if not self._is_application_endpoint:
# Application-scoped response APIs do not support "agent" property.
agent_reference = await self._get_agent_reference_or_create(run_options, instructions)
agent_reference = await self._get_agent_reference_or_create(run_options, instructions, chat_options)
run_options["extra_body"] = {"agent": agent_reference}
# Remove properties that are not supported on request level
# but were configured on agent level
exclude = ["model", "tools", "response_format", "temperature", "top_p"]
exclude = ["model", "tools", "response_format", "temperature", "top_p", "text", "text_format"]
for property in exclude:
run_options.pop(property, None)
return run_options
def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Transform input items to match Azure AI Projects expected schema.
WORKAROUND: Azure AI Projects 'create responses' API expects a different schema than OpenAI's
Responses API. Azure requires 'type' at the item level, and requires 'annotations'
only for output_text content items (assistant messages), not for input_text content items
(user messages). This helper adapts the OpenAI-style input to the Azure schema.
See: https://github.com/Azure/azure-sdk-for-python/issues/44493
TODO(agent-framework#2926): Remove when Azure SDK aligns with OpenAI schema.
"""
transformed: list[dict[str, Any]] = []
for item in input_items:
new_item: dict[str, Any] = dict(item)
# Add 'type': 'message' at item level for role-based items
if "role" in new_item and "type" not in new_item:
new_item["type"] = "message"
# Add 'annotations' only to output_text content items (assistant messages)
# User messages (input_text) do NOT support annotations in Azure AI
if "content" in new_item and isinstance(new_item["content"], list):
new_content: list[dict[str, Any] | Any] = []
for content_item in new_item["content"]:
if isinstance(content_item, dict):
new_content_item: dict[str, Any] = dict(content_item)
# Only add annotations to output_text (assistant content)
if new_content_item.get("type") == "output_text" and "annotations" not in new_content_item:
new_content_item["annotations"] = []
new_content.append(new_content_item)
else:
new_content.append(content_item)
new_item["content"] = new_content
transformed.append(new_item)
return transformed
@override
def _get_current_conversation_id(self, chat_options: ChatOptions, **kwargs: Any) -> str | None:
"""Get the current conversation ID from chat options or kwargs."""
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251223"
version = "1.0.0b260106"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -286,6 +286,93 @@ async def test_azure_ai_client_prepare_messages_for_azure_ai_no_system_messages(
assert instructions is None
def test_azure_ai_client_transform_input_for_azure_ai(mock_project_client: MagicMock) -> None:
"""Test _transform_input_for_azure_ai adds required fields for Azure AI schema.
WORKAROUND TEST: Azure AI Projects API requires 'type' at item level and
'annotations' in output_text content items, which OpenAI's Responses API does not require.
See: https://github.com/Azure/azure-sdk-for-python/issues/44493
See: https://github.com/microsoft/agent-framework/issues/2926
"""
client = create_test_azure_ai_client(mock_project_client)
# Input in OpenAI Responses API format (what agent-framework generates)
openai_format_input = [
{
"role": "user",
"content": [
{"type": "input_text", "text": "Hello"},
],
},
{
"role": "assistant",
"content": [
{"type": "output_text", "text": "Hi there!"},
],
},
]
result = client._transform_input_for_azure_ai(openai_format_input) # type: ignore
# Verify 'type': 'message' added at item level
assert result[0]["type"] == "message"
assert result[1]["type"] == "message"
# Verify 'annotations' added ONLY to output_text (assistant) content, NOT input_text (user)
assert result[0]["content"][0]["type"] == "input_text" # user content type preserved
assert "annotations" not in result[0]["content"][0] # user message - no annotations
assert result[1]["content"][0]["type"] == "output_text" # assistant content type preserved
assert result[1]["content"][0]["annotations"] == [] # assistant message - has annotations
# Verify original fields preserved
assert result[0]["role"] == "user"
assert result[0]["content"][0]["text"] == "Hello"
assert result[1]["role"] == "assistant"
assert result[1]["content"][0]["text"] == "Hi there!"
def test_azure_ai_client_transform_input_preserves_existing_fields(mock_project_client: MagicMock) -> None:
"""Test _transform_input_for_azure_ai preserves existing type and annotations."""
client = create_test_azure_ai_client(mock_project_client)
# Input that already has the fields (shouldn't duplicate)
input_with_fields = [
{
"type": "message",
"role": "assistant",
"content": [
{"type": "output_text", "text": "Hello", "annotations": [{"some": "annotation"}]},
],
},
]
result = client._transform_input_for_azure_ai(input_with_fields) # type: ignore
# Should preserve existing values, not overwrite
assert result[0]["type"] == "message"
assert result[0]["content"][0]["annotations"] == [{"some": "annotation"}]
def test_azure_ai_client_transform_input_handles_non_dict_content(mock_project_client: MagicMock) -> None:
"""Test _transform_input_for_azure_ai handles non-dict content items."""
client = create_test_azure_ai_client(mock_project_client)
# Input with string content (edge case)
input_with_string_content = [
{
"role": "user",
"content": ["plain string content"],
},
]
result = client._transform_input_for_azure_ai(input_with_string_content) # type: ignore
# Should add 'type': 'message' at item level even with non-dict content
assert result[0]["type"] == "message"
# Non-dict content items should be preserved without modification
assert result[0]["content"] == ["plain string content"]
async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicMock) -> None:
"""Test prepare_options basic functionality."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
@@ -636,9 +723,10 @@ async def test_azure_ai_client_agent_creation_with_response_format(
mock_agent.version = "1.0"
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent)
run_options = {"model": "test-model", "response_format": ResponseFormatModel}
run_options = {"model": "test-model"}
chat_options = ChatOptions(response_format=ResponseFormatModel)
await client._get_agent_reference_or_create(run_options, None) # type: ignore
await client._get_agent_reference_or_create(run_options, None, chat_options) # type: ignore
# Verify agent was created with response format configuration
call_args = mock_project_client.agents.create_version.call_args
@@ -689,19 +777,18 @@ async def test_azure_ai_client_agent_creation_with_mapping_response_format(
"additionalProperties": False,
}
run_options = {
"model": "test-model",
"response_format": {
"type": "json_schema",
"json_schema": {
"name": runtime_schema["title"],
"strict": True,
"schema": runtime_schema,
},
run_options = {"model": "test-model"}
response_format_mapping = {
"type": "json_schema",
"json_schema": {
"name": runtime_schema["title"],
"strict": True,
"schema": runtime_schema,
},
}
chat_options = ChatOptions(response_format=response_format_mapping) # type: ignore
await client._get_agent_reference_or_create(run_options, None) # type: ignore
await client._get_agent_reference_or_create(run_options, None, chat_options) # type: ignore
call_args = mock_project_client.agents.create_version.call_args
created_definition = call_args[1]["definition"]
@@ -718,7 +805,7 @@ async def test_azure_ai_client_agent_creation_with_mapping_response_format(
async def test_azure_ai_client_prepare_options_excludes_response_format(
mock_project_client: MagicMock,
) -> None:
"""Test that prepare_options excludes response_format from final run options."""
"""Test that prepare_options excludes response_format, text, and text_format from final run options."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
@@ -728,7 +815,12 @@ async def test_azure_ai_client_prepare_options_excludes_response_format(
patch.object(
client.__class__.__bases__[0],
"_prepare_options",
return_value={"model": "test-model", "response_format": ResponseFormatModel},
return_value={
"model": "test-model",
"response_format": ResponseFormatModel,
"text": {"format": {"type": "json_schema", "name": "test"}},
"text_format": ResponseFormatModel,
},
),
patch.object(
client,
@@ -738,8 +830,11 @@ async def test_azure_ai_client_prepare_options_excludes_response_format(
):
run_options = await client._prepare_options(messages, chat_options)
# response_format should be excluded from final run options
# response_format, text, and text_format should be excluded from final run options
# because they are configured at agent level, not request level
assert "response_format" not in run_options
assert "text" not in run_options
assert "text_format" not in run_options
# But extra_body should contain agent reference
assert "extra_body" in run_options
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
@@ -922,3 +1017,91 @@ async def test_azure_ai_chat_client_agent_with_tools() -> None:
assert response.text is not None
assert len(response.text) > 0
assert any(word in response.text.lower() for word in ["sunny", "25"])
class ReleaseBrief(BaseModel):
"""Structured output model for release brief."""
title: str = Field(description="A short title for the release.")
summary: str = Field(description="A brief summary of what was released.")
highlights: list[str] = Field(description="Key highlights from the release.")
model_config = ConfigDict(extra="forbid")
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_azure_ai_chat_client_agent_with_response_format() -> None:
"""Test ChatAgent with response_format (structured output) using AzureAIClient."""
async with (
temporary_chat_client(agent_name="ResponseFormatAgent") as chat_client,
ChatAgent(chat_client=chat_client) as agent,
):
response = await agent.run(
"Summarize the following release notes into a ReleaseBrief:\n\n"
"Version 2.0 Release Notes:\n"
"- Added new streaming API for real-time responses\n"
"- Improved error handling with detailed messages\n"
"- Performance boost of 50% in batch processing\n"
"- Fixed memory leak in connection pooling",
response_format=ReleaseBrief,
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.value is not None
assert isinstance(response.value, ReleaseBrief)
# Validate structured output fields
brief = response.value
assert len(brief.title) > 0
assert len(brief.summary) > 0
assert len(brief.highlights) > 0
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_azure_ai_chat_client_agent_with_runtime_json_schema() -> None:
"""Test ChatAgent with runtime JSON schema (structured output) using AzureAIClient."""
runtime_schema = {
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": ["location", "conditions", "temperature_c", "advisory"],
"additionalProperties": False,
}
async with (
temporary_chat_client(agent_name="RuntimeSchemaAgent") as chat_client,
ChatAgent(chat_client=chat_client) as agent,
):
response = await agent.run(
"Give a brief weather digest for Seattle.",
additional_chat_options={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": runtime_schema["title"],
"strict": True,
"schema": runtime_schema,
},
},
},
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
# Parse JSON and validate structure
import json
parsed = json.loads(response.text)
assert "location" in parsed
assert "conditions" in parsed
assert "temperature_c" in parsed
assert "advisory" in parsed
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251223"
version = "1.0.0b260106"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -1,102 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Callbacks Sample
Tests the callbacks sample for event tracking and management.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite or Azure Storage account configured
Usage:
uv run pytest packages/azurefunctions/tests/integration_tests/test_03_callbacks.py -v
"""
from typing import Any
import pytest
import requests
from .testutils import (
TIMEOUT,
SampleTestHelper,
skip_if_azure_functions_integration_tests_disabled,
)
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.sample("03_callbacks"),
pytest.mark.usefixtures("function_app_for_test"),
skip_if_azure_functions_integration_tests_disabled,
]
class TestSampleCallbacks:
"""Tests for 03_callbacks sample."""
@pytest.fixture(autouse=True)
def _set_base_url(self, base_url: str) -> None:
"""Provide the callback agent base URL for each test."""
self.base_url = f"{base_url}/api/agents/CallbackAgent"
@staticmethod
def _wait_for_callback_events(base_url: str, thread_id: str) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
response = SampleTestHelper.get(f"{base_url}/callbacks/{thread_id}")
if response.status_code == 200:
events = response.json()
return events
def test_agent_with_callbacks(self) -> None:
"""Test agent execution with callback tracking."""
thread_id = "test-callback"
response = SampleTestHelper.post_json(
f"{self.base_url}/run",
{"message": "Tell me about Python", "thread_id": thread_id},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
events = self._wait_for_callback_events(self.base_url, thread_id)
assert events
assert any(event.get("event_type") == "final" for event in events)
def test_get_callbacks(self) -> None:
"""Test retrieving callback events."""
thread_id = "test-callback-retrieve"
# Send a message first
SampleTestHelper.post_json(
f"{self.base_url}/run",
{"message": "Hello", "thread_id": thread_id, "wait_for_response": False},
)
# Get callbacks
response = SampleTestHelper.get(f"{self.base_url}/callbacks/{thread_id}")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
def test_delete_callbacks(self) -> None:
"""Test clearing callback events."""
thread_id = "test-callback-delete"
# Send a message first
SampleTestHelper.post_json(
f"{self.base_url}/run",
{"message": "Test", "thread_id": thread_id, "wait_for_response": False},
)
# Delete callbacks
response = requests.delete(f"{self.base_url}/callbacks/{thread_id}", timeout=TIMEOUT)
assert response.status_code == 204
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,125 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Reliable Streaming Sample
Tests the reliable streaming sample using Redis Streams for persistent message delivery.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite or Azure Storage account configured
- Redis running (docker run -d --name redis -p 6379:6379 redis:latest)
Usage:
uv run pytest packages/azurefunctions/tests/integration_tests/test_03_reliable_streaming.py -v
"""
import time
import pytest
import requests
from .testutils import (
SampleTestHelper,
skip_if_azure_functions_integration_tests_disabled,
)
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.sample("03_reliable_streaming"),
pytest.mark.usefixtures("function_app_for_test"),
skip_if_azure_functions_integration_tests_disabled,
]
class TestSampleReliableStreaming:
"""Tests for 03_reliable_streaming sample."""
@pytest.fixture(autouse=True)
def _set_base_url(self, base_url: str) -> None:
"""Provide the base URL for each test."""
self.base_url = base_url
self.agent_url = f"{base_url}/api/agents/TravelPlanner"
self.stream_url = f"{base_url}/api/agent/stream"
def test_agent_run_and_stream(self) -> None:
"""Test agent execution with Redis streaming."""
# Start agent run
response = SampleTestHelper.post_json(
f"{self.agent_url}/run",
{"message": "Plan a 1-day trip to Seattle in 1 sentence", "wait_for_response": False},
)
assert response.status_code == 202
data = response.json()
thread_id = data.get("thread_id")
# Wait a moment for the agent to start writing to Redis
time.sleep(2)
# Stream response from Redis with shorter timeout
# Note: We use text/plain to avoid SSE parsing complexity
stream_response = requests.get(
f"{self.stream_url}/{thread_id}",
headers={"Accept": "text/plain"},
timeout=30, # Shorter timeout for test
)
assert stream_response.status_code == 200
def test_stream_with_sse_format(self) -> None:
"""Test streaming with Server-Sent Events format."""
# Start agent run
response = SampleTestHelper.post_json(
f"{self.agent_url}/run",
{"message": "What's the weather like?", "wait_for_response": False},
)
assert response.status_code == 202
data = response.json()
thread_id = data.get("thread_id")
# Wait for agent to start writing
time.sleep(2)
# Stream with SSE format
stream_response = requests.get(
f"{self.stream_url}/{thread_id}",
headers={"Accept": "text/event-stream"},
timeout=30, # Shorter timeout
)
assert stream_response.status_code == 200
content_type = stream_response.headers.get("content-type", "")
assert "text/event-stream" in content_type
# Check for SSE event markers if we got content
content = stream_response.text
if content:
assert "event:" in content or "data:" in content
def test_stream_nonexistent_conversation(self) -> None:
"""Test streaming from a non-existent conversation.
The endpoint will wait for data in Redis, but since the conversation
doesn't exist, it will timeout. This is expected behavior.
"""
fake_id = "nonexistent-conversation-12345"
# Should timeout since the conversation doesn't exist
with pytest.raises(requests.exceptions.ReadTimeout):
requests.get(
f"{self.stream_url}/{fake_id}",
headers={"Accept": "text/plain"},
timeout=10, # Short timeout for non-existent ID
)
def test_health_endpoint(self) -> None:
"""Test health check endpoint."""
response = SampleTestHelper.get(f"{self.base_url}/api/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "agents" in data
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251120"
version = "1.0.0b260106"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251223"
version = "1.0.0b260106"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251223"
version = "1.0.0b260106"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -101,7 +101,7 @@ class ChatClientProtocol(Protocol):
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -160,7 +160,7 @@ class ChatClientProtocol(Protocol):
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -501,7 +501,7 @@ class BaseChatClient(SerializationMixin, ABC):
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -596,7 +596,7 @@ class BaseChatClient(SerializationMixin, ABC):
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -688,12 +688,18 @@ class BaseChatClient(SerializationMixin, ABC):
chat_options: The chat options to prepare.
"""
chat_tool_mode = chat_options.tool_choice
if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none":
# Explicitly disabled: clear tools and set to NONE
if chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none":
chat_options.tools = None
chat_options.tool_choice = ToolMode.NONE
return
# No tools available: set to NONE regardless of requested mode
if not chat_options.tools:
chat_options.tool_choice = ToolMode.NONE
# Tools available but no explicit mode: default to AUTO
elif chat_tool_mode is None:
chat_options.tool_choice = ToolMode.AUTO
# Tools available with explicit mode: preserve the mode
else:
chat_options.tool_choice = chat_tool_mode
@@ -1869,6 +1869,9 @@ def _prepare_function_call_results_as_dumpable(content: Contents | Any | list[Co
return content.model_dump()
if hasattr(content, "to_dict"):
return content.to_dict(exclude={"raw_representation", "additional_properties"})
# Handle objects with text attribute (e.g., MCP TextContent)
if hasattr(content, "text") and isinstance(content.text, str):
return content.text
return content

Some files were not shown because too many files have changed in this diff Show More