Compare commits

..
Author SHA1 Message Date
SergeyMenshykh 24ebbb73d1 disable intermittently failing integration test. 2026-02-13 15:58:18 +00:00
SergeyMenshykh b4e5f8a064 address pr review feedback 2026-02-13 15:24:00 +00:00
SergeyMenshykh b77dbbf85c forgotten change 2026-02-13 15:09:47 +00:00
SergeyMenshykh 87b97dac38 merge with latest main 2026-02-13 15:08:47 +00:00
3168eb4870 .NET: [BREAKING] Add session StateBag for state storage and support multiple providers on the Agent (#3806)
* .NET: [BREAKING] Add session statebag to use for state storage instead of inside providers (#3737)

* Add a StateBag to AgentSession and pass Agent and AgentSession to AIContextProvider and ChatHistoryProviders

* Convert all AIContextProviders to use the statebag

* Update InMemoryChatHistoryProvider to use StateBag

* Update Comsos and Workflow ChatHistoryProviders

* Update 3rd party chat history storage sample.

* Remove serialize method from providers

* Replacing provider factories with properties

* Remove Providers from Session and flatten state bag serialization

* Update samples to use getservice on agent

* Updated additional session types to serialize statebag

* Fix regression

* Address PR comments

* Address PR comments.

* Fix formatting

* Fix unit tests

* Remove InMemoryAgentSession since it is not required anymore.

* Address PR comments

* Convert sessions for A2AAgent, ChatClientAgent, CopilotStudioAgent and GithubCopilotAgent to use regular json serialization.

* Fix durable agent session jso usgae

* Add jso to InMemory and Workflow ChatHistoryProviders

* Update InMemoryChatHistoryProvider to use an options class for it's many optional settings.

* Apply suggestions from code review

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

* Address PR feedback

* Fix verification bug.

* Improve state bag thread safety

* Address PR comments and fix unit tests

* Address PR comments

* Fix unit test

---------

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

* Add a public StateKey property to providers (#3810)

* .NET: [BREAKING] Update providers in such a way that they can participate in a pipeline (#3846)

* Make providers pipeline capable

* Fix unit tests

* Move source stamping to providers from base class

* Also update samples.

* Address PR comments

* Rename AsAgentRequestMessageSourcedMessage to WithAgentRequestMessageSource

* .NET: [BREAKING] Add consistent message filtering to all providers. (#3851)

* Add consistent message filtering to all providers.

* Remove old chat history filtering classes

* Fix merge issues

* Fix unit test

* Enforce non-nullable property

* Fix merging bug and make troubleshooting source info easier by adding tostring implementation

* .NET: [BREAKING] Add support for multiple AIContextProviders on a ChatClientAgent (#3863)

* Add support for multiple AIContextProviders on a ChatClientAgent

* Address PR comments and fix tests

* Address PR comments.

* .NET: [BREAKING]Delay AIContext Materialization until the end of the pipeline is reached. (#3883)

* Delay AIContext Materialization until the end of the pipeline is reached.

* Address PR comments.

* Address PR comments

* Modify InvokedContext to be immutable (#3888)

* .NET: Address Feedback on StateBag feature branch PR (#3910)

* Address Feedback on statebag feature branch PR

* Update dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md

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

* Address PR comments

---------

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 14:08:07 +00:00
Eduard van ValkenburgandGitHub 4452997e8d Python: Replace wildcard imports with explicit imports (#3908)
* Python: Replace wildcard imports with explicit imports

- Replace all 'from ... import *' with explicit symbol imports
- Add __all__ declarations to namespace packages for re-exports
- Update CODING_STANDARD.md to prohibit wildcard imports
- Maintain exported API and preserve all functionality

fixes #3605

* Refine wildcard guidance example text

* Simplify explicit exports without self-aliases
2026-02-13 14:02:36 +00:00
Eduard van ValkenburgandGitHub a39fd69f76 Add memory run snippet tag for docs extraction (#3921) 2026-02-13 13:56:19 +00:00
Eduard van ValkenburgandGitHub f3ea872156 Add default in-memory history provider for workflow agents (#3918) 2026-02-13 13:55:39 +00:00
Eduard van ValkenburgandGitHub e9b3a5bbc7 Python: fix: prevent repeating instructions in continued Responses API conversations (#3909)
* fix: prevent repeating instructions in continued Responses API conversations

- Instructions are now only prepended to messages on the first turn
- When conversation_id/response_id exists (continuation), instructions are skipped
- Covers OpenAI and Azure Responses API paths
- Adds regression tests for all continuation scenarios

Fixes #3498

* Apply lint fixes to continuation tests

* Consolidate responses continuation tests
2026-02-13 13:34:15 +00:00
2168d592ec .NET: Disable irrelevant integration test (#3913)
* disable irrelevant integration test

* Update dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 11:30:58 +00:00
SergeyMenshykhandGitHub 2f7c8c5a34 Merge branch 'main' into feature-so 2026-02-13 10:05:45 +00:00
SergeyMenshykhandGitHub 19ff980b6e .NET: Preserve caller context (#3803)
* fix stuck orchestration

* add previously removed RunAsync<T> method to DurableAIAgent

* suppress IDE0005 warning

* update changelog and remove unused constructor of AgentResponse<T>

* updatge the changelog

* address PR review feedback
2026-02-13 10:04:07 +00:00
ChrisandGitHub 77e90e6013 .NET Workflows - Rename agent-provider and add comments (Declarative Workflows) (#3895)
* Renamed with comments

* Fix rename arcs

* Integration tests
2026-02-13 03:21:41 +00:00
65e77e52af update package versions (#3902)
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-02-13 00:00:57 +00:00
e064f943ae Python: Remove duplicate samples (#3899)
* Remove duplicate samples

* Correct paths

* Update readme

* Update readme

* Fix ruff

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-02-12 23:46:41 +00:00
Tao ChenandGitHub 1441fd903c Python: Fix non-ascii chars in span attributes (#3894)
* Fix non-ascii chars in span attributes

* Comments
2026-02-12 22:53:32 +00:00
Evan MattsonandGitHub a276c1295a Python: Fix streamed workflow agent continuation context by finalizing AgentExecutor streams (#3882)
* Fix streamed workflow agent continuation context by finalizing AgentExecutor streams

* Fix stream handling

* Fixes

* Fix DevUI and tests
2026-02-12 22:45:46 +00:00
Evan MattsonandGitHub 2203fa0f8b Python: (ag-ui): fix Workflow.as_agent() streaming regression (#3875)
* fix Workflow.as_agent() streaming regression in ag-ui

* Address PR feedback

* PR feedback
2026-02-12 22:43:44 +00:00
Eduard van ValkenburgandGitHub 1e350ea22f Python: [BREAKING] PR2 — Wire context provider pipeline, remove old types, update all consumers (#3850)
* PR2: Wire context provider pipeline and update all internal consumers

- Replace AgentThread with AgentSession across all packages
- Replace ContextProvider with BaseContextProvider across all packages
- Replace context_provider param with context_providers (Sequence)
- Replace thread= with session= in run() signatures
- Replace get_new_thread() with create_session()
- Add get_session(service_session_id) to agent interface
- DurableAgentThread -> DurableAgentSession
- Remove _notify_thread_of_new_messages from WorkflowAgent
- Wire before_run/after_run context provider pipeline in RawAgent
- Auto-inject InMemoryHistoryProvider when no providers configured

* fix: update all tests for context provider pipeline, fix lazy-loaders, remove old test files

* refactor: update all sample files for context provider pipeline (AgentThread→AgentSession, ContextProvider→BaseContextProvider)

* fix: update remaining ag-ui references (client docstring, getting_started sample)

* fix: make get_session service_session_id keyword-only to avoid confusion with session_id

* refactor: rename _RunContext.thread_messages to session_messages

* refactor: remove _threads.py, _memory.py, and old provider files; migrate devui to use plain message lists

* rename: remove _new_ prefix from test files

* refactor: rewrite SlidingWindowChatMessageStore as SlidingWindowHistoryProvider(InMemoryHistoryProvider)

* fix: read full history from session state directly instead of reaching into provider internals

* fix: update stale .pyi stubs, sample imports, and README references for new provider types

* fix: remove stale message_store, _notify_thread_of_new_messages, and session_id.key references in samples

* refactor: merge context_providers and sessions sample folders into sessions, remove aggregate_context_provider

* refactor: UserInfoMemory stores state in session.state instead of instance attributes

* feat: add Pydantic BaseModel support to session state serialization

Pydantic models stored in session.state are now automatically serialized
via model_dump() and restored via model_validate() during to_dict()/from_dict()
round-trips. Models are auto-registered on first serialization; use
register_state_type() for cold-start deserialization.

Also export register_state_type as a public API.

* fix mem0

* Update sample README links and descriptions for session terminology

- Replace 'thread' with 'session' in sample descriptions across all READMEs
- Update file links for renamed samples (mem0_sessions, redis_sessions, etc.)
- Fix Threads section → Sessions section in main samples/README.md
- Update tools, middleware, workflows, durabletask, azure_functions READMEs
- Update architecture diagrams in concepts/tools/README.md
- Update migration guides (autogen, semantic-kernel)

* Fix broken Redis README link to renamed sample

* Fix Mem0 OSS client search: pass scoping params as direct kwargs

AsyncMemory (OSS) expects user_id/agent_id/run_id as direct kwargs,
while AsyncMemoryClient (Platform) expects them in a filters dict.
Adds tests for both client types.

Port of fix from #3844 to new Mem0ContextProvider.

* Fix rebase issues: restore missing _conversation_state.py and checkpoint decode logic

- Add back _conversation_state.py (encode/decode_chat_messages) lost in rebase
- Fix on_checkpoint_restore to decode cache/conversation with decode_chat_messages
- Fix on_checkpoint_restore to use decode_checkpoint_value for pending requests
- Add tests/workflow/__init__.py for relative import support
- Fix test_agent_executor checkpoint selection (checkpoints[1] not superstep)

* Add STORES_BY_DEFAULT ClassVar to skip redundant InMemoryHistoryProvider injection

Chat clients that store history server-side by default (OpenAI Responses API,
Azure AI Agent) now declare STORES_BY_DEFAULT = True. The agent checks this
during auto-injection and skips InMemoryHistoryProvider unless the user
explicitly sets store=False.

* Fix broken markdown links in azure_ai and redis READMEs

* Fix getting-started samples to use session API instead of removed thread/ContextProvider API

* updates to workflow as agent

* fix group chat import

* Rename Thread→Session throughout, fix service_session_id propagation, remove stale AGUIThread

- Fix: Propagate conversation_id from ChatResponse back to session.service_session_id
  in both streaming and non-streaming paths in _agents.py
- Rename AgentThreadException → AgentSessionException
- Remove stale AGUIThread from ag_ui lazy-loader
- Rename use_service_thread → use_service_session in ag-ui package
- Rename test functions from *_thread_* to *_session_*
- Rename sample files from *_thread* to *_session*
- Update docstrings and comments: thread → session
- Update _mcp.py kwargs filter: add 'session' alongside 'thread'
- Fix ContinuationToken docstring example: thread=thread → session=session
- Fix _clients.py docstring: 'Agent threads' → 'Agent sessions'

* Fix broken markdown links after thread→session file renames

* fix azure ai test
2026-02-12 21:00:32 +00:00
SergeyMenshykhandGitHub 6dda25c499 .NET: Move SO agent to samples (#3820)
* move SO agent to samples

* change file encoding

* fix files encoding
2026-02-11 11:11:30 +00:00
SergeyMenshykh 04abdbdc5d reflect decision to have SO decorator as a reference implementation in samples 2026-02-10 19:31:04 +00:00
SergeyMenshykh 8635783cd2 rename adr file name 2026-02-10 12:18:14 +00:00
SergeyMenshykh 7b48633675 address review comments 2026-02-09 14:16:51 +00:00
SergeyMenshykh 6f64fdb0b6 fix compilation issue 2026-02-09 14:03:59 +00:00
SergeyMenshykh be904425d3 add missed change 2026-02-09 13:55:19 +00:00
SergeyMenshykh 7966410699 merge with latest main 2026-02-09 13:26:06 +00:00
SergeyMenshykh 30fe0ee84f add adr 2026-02-09 12:34:07 +00:00
SergeyMenshykhandGitHub c26a773657 .NET: Support primitives and arrays for SO (#3696)
* wrap primitives and arrays

* fix file encoding

* address review comments
2026-02-06 09:27:45 +00:00
9991eb6e88 .NET: Add decorator for structured output support (#3694)
* add decorator that adds structured output support to agents that don't natively support it.

* Update dotnet/src/Microsoft.Agents.AI/StructuredOutput/StructuredOutputAgentResponse.cs

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

* Update dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs

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

* address pr review feedback

---------

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
2026-02-05 18:50:51 +00:00
1e20c69cbd .NET:[Breaking] Add support for structured output (#3658)
* add support for so

* restore lost xml comment part

* fix using ordering

* Update dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs

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

* Update dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs

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

* Update dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_SO_WithFormatResponseTests.cs

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

* addressw pr review comments

* address pr review feedback

* address pr review comments

* fix compilation issues after the latest merge with main

* remove unnecessry options

* remove RunAsync<object> methods

* address code review feedback

* address pr review feedback

* make copy constructor protected

* address pr review feedback

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-04 19:47:36 +00:00
SergeyMenshykh 0709f6df06 Merge branch 'main' into feature-so 2026-02-04 10:49:12 +00:00
3df8fe3c3f .NET: Delete AgentResponse.{Try}Deserialize<T> methods (#3518)
* delete deserialize method of agent response

* order usings

* Update dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs

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

* Update dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs

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

* Update dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs

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

* Update dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs

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

* Update dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 14:30:41 +00:00
467 changed files with 10991 additions and 15281 deletions
+658
View File
@@ -0,0 +1,658 @@
---
status: proposed
contact: sergeymenshykh
date: 2026-01-22
deciders: rbarreto, westey-m, stephentoub
informed: {}
---
# Structured Output
Structured output is a valuable aspect of any agent system, since it forces an agent to produce output in a required format that may include required fields.
This allows easily turning unstructured data into structured data using a general-purpose language model.
## Context and Problem Statement
Structured output is currently supported only by `ChatClientAgent` and can be configured in two ways:
**Approach 1: ResponseFormat + Deserialize**
Specify the SO type schema via the `ChatClientAgent{Run}Options.ChatOptions.ResponseFormat` property at agent creation or invocation time, then use `JsonSerializer.Deserialize<T>` to extract the structured data from the response text.
```csharp
// SO type can be provided at agent creation time
ChatClientAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
{
Name = "...",
ChatOptions = new() { ResponseFormat = ChatResponseFormat.ForJsonSchema<PersonInfo>() }
});
AgentResponse response = await agent.RunAsync("...");
PersonInfo personInfo = response.Deserialize<PersonInfo>(JsonSerializerOptions.Web);
Console.WriteLine($"Name: {personInfo.Name}");
Console.WriteLine($"Age: {personInfo.Age}");
Console.WriteLine($"Occupation: {personInfo.Occupation}");
// Alternatively, SO type can be provided at agent invocation time
response = await agent.RunAsync("...", new ChatClientAgentRunOptions()
{
ChatOptions = new() { ResponseFormat = ChatResponseFormat.ForJsonSchema<PersonInfo>() }
});
personInfo = response.Deserialize<PersonInfo>(JsonSerializerOptions.Web);
Console.WriteLine($"Name: {personInfo.Name}");
Console.WriteLine($"Age: {personInfo.Age}");
Console.WriteLine($"Occupation: {personInfo.Occupation}");
```
**Approach 2: Generic RunAsync<T>**
Supply the SO type as a generic parameter to `RunAsync<T>` and access the parsed result directly via the `Result` property.
```csharp
ChatClientAgent agent = ...;
AgentResponse<PersonInfo> response = await agent.RunAsync<PersonInfo>("...");
Console.WriteLine($"Name: {response.Result.Name}");
Console.WriteLine($"Age: {response.Result.Age}");
Console.WriteLine($"Occupation: {response.Result.Occupation}");
```
Note: `RunAsync<T>` is an instance method of `ChatClientAgent` and not part of the `AIAgent` base class since not all agents support structured output.
Approach 1 is perceived as cumbersome by the community, as it requires additional effort when using primitive or collection types - the SO schema may need to be wrapped in an artificial JSON object. Otherwise, the caller will encounter an error like _Invalid schema for response_format 'Movie': schema must be a JSON Schema of 'type: "object"', got 'type: "array"'_.
This occurs because OpenAI and compatible APIs require a JSON object as the root schema.
Approach 1 is also necessary in scenarios where (a) agents can only be configured with SO at creation time (such as with `AIProjectClient`), (b) the SO type is not known at compile time, or (c) the JSON schema is represented as text (for declarative agents) or as a `JsonElement`.
Approach 2 is more convenient and works seamlessly with primitives and collections. However, it requires the SO type to be known at compile time, making it less flexible.
Additionally, since the `RunAsync<T>` methods are instance methods of `ChatClientAgent` and are not part of the `AIAgent` base class, applying decorators like `OpenTelemetryAgent` on top of `ChatClientAgent` prevents users from accessing `RunAsync<T>`, meaning structured output is not available with decorated agents.
Given the different scenarios above in which structured output can be used, there is no one-size-fits-all solution. Each approach has its own advantages and limitations,
and the two can complement each other to provide a comprehensive structured output experience across various use cases.
## Approaches Overview
1. SO usage via `ResponseFormat` property
2. SO usage via `RunAsync<T>` generic method
## 1. SO usage via `ResponseFormat` property
This approach should be used in the following scenarios:
- 1.1 SO result as text is sufficient as is, and deserialization is not required
- 1.2 SO for inter-agent collaboration
- 1.3 SO can only be configured at agent creation time (such as with `AIProjectClient`)
- 1.4 SO type is not known at compile time and represented by System.Type
- 1.5 SO is represented by JSON schema and there's no corresponding .NET type either at compile time or at runtime
- 1.6 SO in streaming scenarios, where the SO response is produced in parts
**Note: Primitives and arrays are not supported by this approach.**
When a caller provides a schema via `ResponseFormat`, they are explicitly telling the framework what schema to use. The framework passes that schema through as-is and
is not responsible for transforming it. Because the framework does not own the schema, it cannot wrap primitives or arrays into a JSON object to satisfy API requirements,
nor can it unwrap the response afterward - the caller controls the schema and is responsible for ensuring it is compatible with the underlying API.
This is in contrast to the `RunAsync<T>` approach (section 2), where the caller provides a type `T` and says "make it work." In that case, the caller does not
dictate the schema - the framework infers the schema from `T`, owns the end-to-end pipeline (schema generation, API invocation, and deserialization), and can
therefore wrap and unwrap primitives and arrays transparently.
Additionally, in streaming scenarios (1.6), the framework cannot reliably unwrap a response it did not wrap, since it has no way of knowing whether the caller wrapped the schema.Wrapping and unwrapping can only be done safely when the framework owns the entire lifecycle - from schema creation through deserialization — which is only the case with `RunAsync<T>`.
If a caller needs to work with primitives or arrays via the `ResponseFormat` approach, they can easily create a wrapper type around them:
```csharp
public class MovieListWrapper
{
public List<string> Movies { get; set; }
}
```
### 1.1 SO result as text is sufficient as is, and deserialization is not required
In this scenario, the caller only needs the raw JSON text returned by the model and does not need to deserialize it into a .NET type.
The SO schema is specified via `ResponseFormat` at agent creation or invocation time, and the response text is consumed directly from the `AgentResponse`.
```csharp
AIAgent agent = chatClient.AsAIAgent();
AgentRunOptions runOptions = new()
{
ResponseFormat = ChatResponseFormat.ForJsonSchema<PersonInfo>()
};
AgentResponse response = await agent.RunAsync("...", options: runOptions);
Console.WriteLine(response.Text);
```
### 1.2 SO for inter-agent collaboration
This scenario assumes a multi-agent setup where agents collaborate by passing messages to each other.
One agent produces structured output as text that is then passed directly as input to the next agent, without intermediate deserialization.
```csharp
// First agent extracts structured data from unstructured input
AIAgent extractionAgent = chatClient.AsAIAgent(new ChatClientAgentOptions()
{
Name = "ExtractionAgent",
ChatOptions = new()
{
Instructions = "Extract person information from the provided text.",
ResponseFormat = ChatResponseFormat.ForJsonSchema<PersonInfo>()
}
});
AgentResponse extractionResponse = await extractionAgent.RunAsync("John Smith is a 35-year-old software engineer.");
// Pass the message with structured output text directly to the next agent
ChatMessage soMessage = extractionResponse.Messages.Last();
AIAgent summaryAgent = chatClient.AsAIAgent(new ChatClientAgentOptions()
{
Name = "SummaryAgent",
ChatOptions = new() { Instructions = "Given the following structured person data, write a short professional bio." }
});
AgentResponse summaryResponse = await summaryAgent.RunAsync(soMessage);
Console.WriteLine(summaryResponse);
```
### 1.3 SO configured at agent creation time
In this scenario, the SO schema can only be configured at agent creation time (such as with `AIProjectClient`) and cannot be changed on a per-run basis.
The caller specifies the `ResponseFormat` when creating the agent, and all subsequent invocations use the same schema.
```csharp
AIProjectClient client = ...;
AIAgent agent = await client.CreateAIAgentAsync(model: "<model>", new ChatClientAgentOptions()
{
Name = "...",
ChatOptions = new() { ResponseFormat = ChatResponseFormat.ForJsonSchema<PersonInfo>() }
});
AgentResponse response = await agent.RunAsync("Please provide information about John Smith.");
PersonInfo personInfo = JsonSerializer.Deserialize<PersonInfo>(response.Text, JsonSerializerOptions.Web)!;
Console.WriteLine($"Name: {personInfo.Name}");
Console.WriteLine($"Age: {personInfo.Age}");
Console.WriteLine($"Occupation: {personInfo.Occupation}");
```
### 1.4 SO type not known at compile time and represented by System.Type
In this scenario, the SO type is not known at compile time and is provided as a `System.Type` at runtime. This is useful for dynamic scenarios where the schema is determined programmatically,
such as when building tooling or frameworks that work with user-defined types.
```csharp
Type soType = GetStructuredOutputTypeFromConfiguration(); // e.g., typeof(PersonInfo)
ChatResponseFormat responseFormat = ChatResponseFormat.ForJsonSchema(soType);
AgentResponse response = await agent.RunAsync("...", new ChatClientAgentRunOptions()
{
ChatOptions = new() { ResponseFormat = responseFormat }
});
PersonInfo personInfo = (PersonInfo)JsonSerializer.Deserialize(response.Text, soType, JsonSerializerOptions.Web)!;
```
### 1.5 SO represented by JSON schema with no corresponding .NET type
In this scenario, the SO schema is represented as raw JSON schema text or a `JsonElement`, and there is no corresponding .NET type available at compile time or runtime.
This is typical for declarative agents or scenarios where schemas are loaded from external configuration.
```csharp
// JSON schema provided as a string, e.g., loaded from a configuration file
string jsonSchema = """
{
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" },
"occupation": { "type": "string" }
},
"required": ["name", "age", "occupation"]
}
""";
ChatResponseFormat responseFormat = ChatResponseFormat.ForJsonSchema(
jsonSchemaName: "PersonInfo",
jsonSchema: BinaryData.FromString(jsonSchema));
AgentResponse response = await agent.RunAsync("...", new ChatClientAgentRunOptions()
{
ChatOptions = new() { ResponseFormat = responseFormat }
});
// Consume the SO result as text since there's no .NET type to deserialize into
Console.WriteLine(response.Text);
```
### 1.6 SO in streaming scenarios
In this scenario, the SO response is produced incrementally in parts via streaming. The caller specifies the `ResponseFormat` and consumes the response chunks as they arrive.
Deserialization is performed after all chunks have been received.
```csharp
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
{
Name = "HelpfulAssistant",
ChatOptions = new()
{
Instructions = "You are a helpful assistant.",
ResponseFormat = ChatResponseFormat.ForJsonSchema<PersonInfo>()
}
});
IAsyncEnumerable<AgentResponseUpdate> updates = agent.RunStreamingAsync("Please provide information about John Smith, who is a 35-year-old software engineer.");
AgentResponse response = await updates.ToAgentResponseAsync();
// Deserialize the complete SO result after streaming is finished
PersonInfo personInfo = JsonSerializer.Deserialize<PersonInfo>(response.Text)!;
```
## 2. SO usage via `RunAsync<T>` generic method
This approach provides a convenient way to work with structured output on a per-run basis when the target type is known at compile time and a typed instance of the result
is required.
### Decision Drivers
1. Support arrays and primitives as SO types
2. Support complex types as SO types
3. Work with `AIAgent` decorators (e.g., `OpenTelemetryAgent`)
4. Enable SO for all AI agents, regardless of whether they natively support it
### Considered Options
1. `RunAsync<T>` as an instance method of `AIAgent` class delegating to virtual `RunCoreAsync<T>`
2. `RunAsync<T>` as an extension method using feature collection
3. `RunAsync<T>` as a method of the new `ITypedAIAgent` interface
4. `RunAsync<T>` as an instance method of `AIAgent` class working via the new `AgentRunOptions.ResponseFormat` property
### 1. `RunAsync<T>` as an instance method of `AIAgent` class delegating to virtual `RunCoreAsync<T>`
This option adds the `RunAsync<T>` method directly to the `AIAgent` base class.
```csharp
public abstract class AIAgent
{
public Task<AgentResponse<T>> RunAsync<T>(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
=> this.RunCoreAsync<T>(messages, session, serializerOptions, options, cancellationToken);
protected virtual Task<AgentResponse<T>> RunCoreAsync<T>(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException($"The agent of type '{this.GetType().FullName}' does not support typed responses.");
}
}
```
Agents with native SO support override the `RunCoreAsync<T>` method to provide their implementation. If not overridden, the method throws a `NotSupportedException`.
Users will call the generic `RunAsync<T>` method directly on the agent:
```csharp
AIAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant.");
AgentResponse<PersonInfo> response = await agent.RunAsync<PersonInfo>("Please provide information about John Smith, who is a 35-year-old software engineer.");
```
Decision drivers satisfied:
1. Support arrays and primitives as SO types
2. Support complex types as SO types
3. Work with `AIAgent` decorators (e.g., `OpenTelemetryAgent`)
4. Enable SO for all AI agents, regardless of whether they natively support it
Pros:
- The `AIAgent.RunAsync<T>` method is easily discoverable.
- Both the SO decorator and `ChatClientAgent` have compile-time access to the type `T`, allowing them to use the native `IChatClient.GetResponseAsync<T>` API, which handles primitives and collections seamlessly.
Cons:
- Agents without native SO support will still expose `RunAsync<T>`, which may be misleading.
- `ChatClientAgent` exposing `RunAsync<T>` may be misleading when the underlying chat client does not support SO.
- All `AIAgent` decorators must override `RunCoreAsync<T>` to properly handle `RunAsync<T>` calls.
### 2. `RunAsync<T>` as an extension method using feature collection
This option uses the Agent Framework feature collection (implemented via `AgentRunOptions.AdditionalProperties`) to pass a `StructuredOutputFeature` to agents, signaling that SO is requested.
Agents with native SO support check for this feature. If present, they read the target type, build the schema, invoke the underlying API, and store the response back in the feature.
```csharp
public class StructuredOutputFeature
{
public StructuredOutputFeature(Type outputType)
{
this.OutputType = outputType;
}
[JsonIgnore]
public Type OutputType { get; set; }
public JsonSerializerOptions? SerializerOptions { get; set; }
public AgentResponse? Response { get; set; }
}
```
The `RunAsync<T>` extension method for `AIAgent` adds this feature to the collection.
```csharp
public static async Task<AgentResponse<T>> RunAsync<T>(
this AIAgent agent,
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
// Create the structured output feature.
StructuredOutputFeature structuredOutputFeature = new(typeof(T))
{
SerializerOptions = serializerOptions,
};
// Register it in the feature collection.
((options ??= new AgentRunOptions()).AdditionalProperties ??= []).Add(typeof(StructuredOutputFeature).FullName!, structuredOutputFeature);
var response = await agent.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
if (structuredOutputFeature.Response is not null)
{
return new StructuredOutputResponse<T>(structuredOutputFeature.Response, response, serializerOptions);
}
throw new InvalidOperationException("No structured output response was generated by the agent.");
}
```
Users will call the `RunAsync<T>` extension method directly on the agent:
```csharp
AIAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant.");
AgentResponse<PersonInfo> response = await agent.RunAsync<PersonInfo>("Please provide information about John Smith, who is a 35-year-old software engineer.");
```
Decision drivers satisfied:
1. Support arrays and primitives as SO types
2. Support complex types as SO types
3. Work with `AIAgent` decorators (e.g., `OpenTelemetryAgent`)
4. Enable SO for all AI agents, regardless of whether they natively support it
Pros:
- The `RunAsync<T>` extension method is easily discoverable.
- The `AIAgent` public API surface remains unchanged.
- No changes required to `AIAgent` decorators.
Cons:
- Agents without native SO support will still expose `RunAsync<T>`, which may be misleading.
- `ChatClientAgent` exposing `RunAsync<T>` may be misleading when the underlying chat client does not support SO.
### 3. `RunAsync<T>` as a method of the new `ITypedAIAgent` interface
This option defines a new `ITypedAIAgent` interface that agents with SO support implement. Agents without SO support do not implement it, allowing users to check for SO capability via interface detection.
The interface:
```csharp
public interface ITypedAIAgent
{
Task<AgentResponse<T>> RunAsync<T>(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default);
...
}
```
Agents with SO support implement this interface:
```csharp
public sealed partial class ChatClientAgent : AIAgent, ITypedAIAgent
{
public async Task<AgentResponse<T>> RunAsync<T>(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
...
}
}
```
However, `ChatClientAgent` presents a challenge: it can work with chat clients that either support or do not support SO. Implementing the interface does not guarantee
the underlying chat client supports SO, which undermines the core idea of using interface detection to determine SO capability.
Additionally, to allow users to access interface methods on decorated agents, all decorators must implement `ITypedAIAgent`. This makes it difficult for users to
determine whether the underlying agent actually supports SO, further weakening the purpose of this approach.
Furthermore, users would have to probe the agent type to check if it implements the `ITypedAIAgent` interface and cast it accordingly to access the `RunAsync<T>` methods.
This adds friction to the user experience. A `RunAsync<T>` extension method for `AIAgent` could be provided to alleviate that.
Given these drawbacks, this option is more complex to implement than the others without providing clear benefits.
Decision drivers satisfied:
1. Support arrays and primitives as SO types
2. Support complex types as SO types
3. Work with `AIAgent` decorators (e.g., `OpenTelemetryAgent`)
4. Enable SO for all AI agents, regardless of whether they natively support it
Pros:
- Both the SO decorator and `ChatClientAgent` have compile-time access to the type `T`, allowing them to use the native `IChatClient.GetResponseAsync<T>` API, which handles primitives and collections seamlessly.
Cons:
- `ChatClientAgent` implementing `ITypedAIAgent` may be misleading when the underlying chat client does not support SO.
- All `AIAgent` decorators must implement `ITypedAIAgent` to handle `RunAsync<T>` calls.
- Decorators implementing the interface may mislead users into thinking the underlying agent natively supports SO.
- Agents must implement all members of `ITypedAIAgent`, not just a core method.
- Users must check the agent type and cast to `ITypedAIAgent` to access `RunAsync<T>`.
### 4. `RunAsync<T>` as an instance method of `AIAgent` class working via the new `AgentRunOptions.ResponseFormat` property
This option adds a `ResponseFormat` property of type `ChatResponseFormat` to `AgentRunOptions`. Agents that support SO check for the presence of
this property in the options passed to `RunAsync` to determine whether structured output is requested. If present, they use the schema from `ResponseFormat`
to invoke the underlying API and obtain the SO response.
```csharp
public class AgentRunOptions
{
public ChatResponseFormat? ResponseFormat { get; set; }
}
```
Additionally, a generic `RunAsync<T>` method is added to `AIAgent` that initializes the `ResponseFormat` based on the type `T` and delegates to the non-generic `RunAsync`.
```csharp
public abstract class AIAgent
{
public async Task<AgentResponse<T>> RunAsync<T>(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
var responseFormat = ChatResponseFormat.ForJsonSchema<T>(serializerOptions);
options = options?.Clone() ?? new AgentRunOptions();
options.ResponseFormat = responseFormat;
AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
return new AgentResponse<T>(response, serializerOptions);
}
}
```
Users call the generic `RunAsync<T>` method directly on the agent:
```csharp
AIAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant.");
AgentResponse<PersonInfo> response = await agent.RunAsync<PersonInfo>("Please provide information about John Smith, who is a 35-year-old software engineer.");
```
Decision drivers satisfied:
1. Support arrays and primitives as SO types
2. Support complex types as SO types
3. Work with `AIAgent` decorators (e.g., `OpenTelemetryAgent`)
4. Enable SO for all AI agents, regardless of whether they natively support it
Pros:
- The `AIAgent.RunAsync<T>` method is easily discoverable.
- No changes required to `AIAgent` decorators
Cons:
- Agents without native SO support will still expose `RunAsync<T>`, which may be misleading.
- `ChatClientAgent` exposing `RunAsync<T>` may be misleading when the underlying chat client does not support SO.
### Decision Table
| | Option 1: Instance method + RunCoreAsync<T> | Option 2: Extension method + feature collection | Option 3: ITypedAIAgent Interface | Option 4: Instance method + AgentRunOptions.ResponseFormat |
|---|---|---|---|---|
| Discoverability | ✅ `RunAsync<T>` easily discoverable | ✅ `RunAsync<T>` easily discoverable | ❌ Requires type check and cast | ✅ `RunAsync<T>` easily discoverable |
| Decorator changes | ❌ All decorators must override `RunCoreAsync<T>` | ✅ No changes required | ❌ All decorators must implement `ITypedAIAgent` | ✅ No changes required to decorators |
| Primitives/collections handling | ✅ Native support via `IChatClient.GetResponseAsync<T>` | ❌ Must wrap/unwrap internally | ✅ Native support via `IChatClient.GetResponseAsync<T>` | ❌ Must wrap/unwrap internally |
| Misleading API exposure | ❌ Agents without SO still expose `RunAsync<T>` | ❌ Agents without SO still expose `RunAsync<T>` | ❌ Interface on `ChatClientAgent` may be misleading | ❌ Agents without SO still expose `RunAsync<T>` |
| Implementation burden | ❌ Decorators must override method | ❌ Must handle schema wrapping | ❌ Agents must implement all interface members | ✅ Delegates to existing `RunAsync` via `ResponseFormat` |
## Cross-Cutting Aspects
1. **The `useJsonSchemaResponseFormat` parameter**: The `ChatClientAgent.RunAsync<T>` method has this parameter to enable structured output on LLMs that do not natively support it.
It works by adding a user message like "Respond with a JSON value conforming to the following schema:" along with the JSON schema. However, this approach has not been reliable historically. The recommendation is not to carry this parameter forward, regardless of which option is chosen.
2. **Primitives and array types handling**: There are a few options for how primitive and array types can be handled in the Agent Framework:
1. **Never wrap**, regardless of whether the schema is provided via `ResponseFormat` or `RunAsync<T>`.
- Pro: No changes needed; user has full control.
- Pro: No issues with unwrapping in streaming scenarios.
- Con: User must wrap manually.
2. **Always wrap**, regardless of whether the schema is provided via `ResponseFormat` or `RunAsync<T>`.
- Pro: Consistent wrapping behavior; no manual wrapping needed.
- Con: Inconsistent unwrapping behavior; it may be unexpected to have SO result wrapped when schema is provided via `ResponseFormat`.
- Con: Impossible to know if SO result is wrapped to unwrap it in streaming scenarios.
3. **Wrap only for `RunAsync<T>`** and do not wrap the schema provided via `ResponseFormat`.
- Pro: No unexpectedly wrapped result when schema is provided via `ResponseFormat`.
- Pro: Solves the problem with unwrapping in streaming scenarios.
4. **User decides** whether to wrap schema provided via `ResponseFormat` using a new `wrapPrimitivesAndArrays` property of `ChatResponseFormatJson`. For SO provided via `RunAsync<T>`, AF always wraps.
- Pro: No manual wrapping needed; just flip a switch.
- Pro: Solves the problem with unwrapping in streaming scenarios.
- Con: Extends the public API surface.
3. **Structured output for agents without native SO support**: Some AI agents in AF do not support structured output natively. This is either because it is not part of the protocol (e.g., A2A agent) or because the agents use LLMs without structured output capabilities.
To address this gap, AF can provide the `StructuredOutputAgent` decorator. This decorator wraps any `AIAgent` and adds structured output support by obtaining the text response from the decorated agent and delegating it to a configured chat client for JSON transformation.
```csharp
public class StructuredOutputAgent : DelegatingAIAgent
{
private readonly IChatClient _chatClient;
public StructuredOutputAgent(AIAgent innerAgent, IChatClient chatClient)
: base(innerAgent)
{
this._chatClient = Throw.IfNull(chatClient);
}
protected override async Task<AgentResponse<T>> RunCoreAsync<T>(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
// Run the inner agent first, to get back the text response we want to convert.
var textResponse = await this.InnerAgent.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
// Invoke the chat client to transform the text output into structured data.
ChatResponse<T> soResponse = await this._chatClient.GetResponseAsync<T>(
messages:
[
new ChatMessage(ChatRole.System, "You are a json expert and when provided with any text, will convert it to the requested json format."),
new ChatMessage(ChatRole.User, textResponse.Text)
],
serializerOptions: serializerOptions ?? AgentJsonUtilities.DefaultOptions,
cancellationToken: cancellationToken).ConfigureAwait(false);
return new StructuredOutputAgentResponse(soResponse, textResponse);
}
}
```
The decorator preserves the original response from the decorated agent and surfaces it via the `OriginalResponse` property on the returned `StructuredOutputAgentResponse`.
This allows users to access both the original unstructured response and the new structured response when using this decorator.
```csharp
public class StructuredOutputAgentResponse : AgentResponse
{
internal StructuredOutputAgentResponse(ChatResponse chatResponse, AgentResponse agentResponse) : base(chatResponse)
{
this.OriginalResponse = agentResponse;
}
public AgentResponse OriginalResponse { get; }
}
```
The decorator can be registered during the agent configuration step using the `UseStructuredOutput` extension method on `AIAgentBuilder`.
```csharp
IChatClient meaiChatClient = chatClient.AsIChatClient();
AIAgent baseAgent = meaiChatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant.");
// Register the StructuredOutputAgent decorator during agent building
AIAgent agent = baseAgent
.AsBuilder()
.UseStructuredOutput(meaiChatClient)
.Build();
AgentResponse<PersonInfo> response = await agent.RunAsync<PersonInfo>("Please provide information about John Smith, who is a 35-year-old software engineer.");
Console.WriteLine($"Name: {response.Result.Name}");
Console.WriteLine($"Age: {response.Result.Age}");
Console.WriteLine($"Occupation: {response.Result.Occupation}");
var originalResponse = ((StructuredOutputAgentResponse)response.RawRepresentation!).OriginalResponse;
Console.WriteLine($"Original unstructured response: {originalResponse.Text}");
```
## Decision Outcome
It was decided to keep both approaches for structured output - via `ResponseFormat` and via `RunAsync<T>` since they serve different scenarios and use cases.
For the `RunAsync<T>` approach, option 4 was selected, which adds a generic `RunAsync<T>` method to `AIAgent` that works via the new `AgentRunOptions.ResponseFormat` property.
This was chosen for its simplicity and because no changes are required to existing `AIAgent` decorators.
For cross-cutting aspects, the `useJsonSchemaResponseFormat` parameter will not be carried forward due to reliability issues.
For handling primitives and array types, option 3 was selected: wrap only for `RunAsync<T>` and do not wrap the schema provided via `ResponseFormat`.
This avoids the issues described in the Approach 1 section note.
Finally, it was decided not to include the `StructuredOutputAgent` decorator in the framework, since the reliability of producing structured output via an additional
LLM call may not be sufficient for all scenarios. Instead, this pattern is provided as a sample to demonstrate how structured output can be achieved for agents without native support,
giving users a reference implementation they can adapt to their own requirements.
+3
View File
@@ -389,6 +389,9 @@
<File Path="src/Shared/Throw/README.md" />
<File Path="src/Shared/Throw/Throw.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/StructuredOutput/">
<File Path="src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs" />
</Folder>
<Folder Name="/Solution Items/tests/">
<File Path="tests/.editorconfig" />
<File Path="tests/Directory.Build.props" />
+3
View File
@@ -20,4 +20,7 @@
<ItemGroup Condition="'$(InjectSharedFoundryAgents)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Foundry\Agents\*.cs" LinkBase="Shared\Foundry" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedStructuredOutput)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\StructuredOutput\*.cs" LinkBase="Shared\StructuredOutput" />
</ItemGroup>
</Project>
@@ -78,7 +78,7 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
var response = allUpdates.ToAgentResponse();
if (response.TryDeserialize(this._jsonSerializerOptions, out JsonElement stateSnapshot))
if (TryDeserialize(response.Text, this._jsonSerializerOptions, out JsonElement stateSnapshot))
{
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
stateSnapshot,
@@ -103,4 +103,25 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
yield return update;
}
}
private static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
{
try
{
T? result = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
if (result is null)
{
structuredOutput = default!;
return false;
}
structuredOutput = result;
return true;
}
catch
{
structuredOutput = default!;
return false;
}
}
}
@@ -107,7 +107,7 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
var response = allUpdates.ToAgentResponse();
// Try to deserialize the structured state response
if (response.TryDeserialize(this._jsonSerializerOptions, out JsonElement stateSnapshot))
if (TryDeserialize(response.Text, this._jsonSerializerOptions, out JsonElement stateSnapshot))
{
// Serialize and emit as STATE_SNAPSHOT via DataContent
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
@@ -134,4 +134,25 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
yield return update;
}
}
private static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
{
try
{
T? deserialized = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
if (deserialized is null)
{
structuredOutput = default!;
return false;
}
structuredOutput = deserialized;
return true;
}
catch
{
structuredOutput = default!;
return false;
}
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace SampleApp;
/// <summary>
/// Provides extension methods for adding structured output capabilities to <see cref="AIAgentBuilder"/> instances.
/// </summary>
internal static class AIAgentBuilderExtensions
{
/// <summary>
/// Adds structured output capabilities to the agent pipeline, enabling conversion of text responses to structured JSON format.
/// </summary>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which structured output support will be added.</param>
/// <param name="chatClient">
/// The chat client used to transform text responses into structured JSON format.
/// If <see langword="null"/>, the chat client will be resolved from the service provider.
/// </param>
/// <param name="optionsFactory">
/// An optional factory function that returns the <see cref="StructuredOutputAgentOptions"/> instance to use.
/// This allows for fine-tuning the structured output behavior such as setting the response format or system message.
/// </param>
/// <returns>The <see cref="AIAgentBuilder"/> with structured output capabilities added, enabling method chaining.</returns>
/// <remarks>
/// <para>
/// A <see cref="ChatResponseFormatJson"/> must be specified either through the
/// <see cref="AgentRunOptions.ResponseFormat"/> at runtime or the <see cref="StructuredOutputAgentOptions.ChatOptions"/>
/// provided during configuration.
/// </para>
/// </remarks>
public static AIAgentBuilder UseStructuredOutput(
this AIAgentBuilder builder,
IChatClient? chatClient = null,
Func<StructuredOutputAgentOptions>? optionsFactory = null)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.Use((innerAgent, services) =>
{
chatClient ??= services?.GetService<IChatClient>()
?? throw new InvalidOperationException($"No {nameof(IChatClient)} was provided and none could be resolved from the service provider. Either provide an {nameof(IChatClient)} explicitly or register one in the dependency injection container.");
return new StructuredOutputAgent(innerAgent, chatClient, optionsFactory?.Invoke());
});
}
}
@@ -8,11 +8,13 @@ using System.Text.Json.Serialization;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using SampleApp;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// Create chat client to be used by chat client agents.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
@@ -23,52 +25,159 @@ ChatClient chatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
// Create the ChatClientAgent with the specified name and instructions.
ChatClientAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant.");
// Demonstrates how to work with structured output via ResponseFormat with the non-generic RunAsync method.
// This approach is useful when:
// a. Structured output is used for inter-agent communication, where one agent produces structured output
// and passes it as text to another agent as input, without the need for the caller to directly work with the structured output.
// b. The type of the structured output is not known at compile time, so the generic RunAsync<T> method cannot be used.
// c. The type of the structured output is represented by JSON schema only, without a corresponding class or type in the code.
await UseStructuredOutputWithResponseFormatAsync(chatClient);
// Set PersonInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke the agent with some unstructured input.
AgentResponse<PersonInfo> response = await agent.RunAsync<PersonInfo>("Please provide information about John Smith, who is a 35-year-old software engineer.");
// Demonstrates how to work with structured output via the generic RunAsync<T> method.
// This approach is useful when the caller needs to directly work with the structured output in the code
// via an instance of the corresponding class or type and the type is known at compile time.
await UseStructuredOutputWithRunAsync(chatClient);
// Access the structured output via the Result property of the agent response.
Console.WriteLine("Assistant Output:");
Console.WriteLine($"Name: {response.Result.Name}");
Console.WriteLine($"Age: {response.Result.Age}");
Console.WriteLine($"Occupation: {response.Result.Occupation}");
// Demonstrates how to work with structured output when streaming using the RunStreamingAsync method.
await UseStructuredOutputWithRunStreamingAsync(chatClient);
// Create the ChatClientAgent with the specified name, instructions, and expected structured output the agent should produce.
ChatClientAgent agentWithPersonInfo = chatClient.AsAIAgent(new ChatClientAgentOptions()
// Demonstrates how to add structured output support to agents that don't natively support it using the structured output middleware.
// This approach is useful when working with agents that don't support structured output natively, or agents using models
// that don't have the capability to produce structured output, allowing you to still leverage structured output features by transforming
// the text output from the agent into structured data using a chat client.
await UseStructuredOutputWithMiddlewareAsync(chatClient);
static async Task UseStructuredOutputWithResponseFormatAsync(ChatClient chatClient)
{
Name = "HelpfulAssistant",
ChatOptions = new() { Instructions = "You are a helpful assistant.", ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema<PersonInfo>() }
});
Console.WriteLine("=== Structured Output with ResponseFormat ===");
// Invoke the agent with some unstructured input while streaming, to extract the structured information from.
var updates = agentWithPersonInfo.RunStreamingAsync("Please provide information about John Smith, who is a 35-year-old software engineer.");
// Create the agent
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
{
Name = "HelpfulAssistant",
ChatOptions = new()
{
Instructions = "You are a helpful assistant.",
// Specify CityInfo as the type parameter of ForJsonSchema to indicate the expected structured output from the agent.
ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema<CityInfo>()
}
});
// Assemble all the parts of the streamed output, since we can only deserialize once we have the full json,
// then deserialize the response into the PersonInfo class.
PersonInfo personInfo = (await updates.ToAgentResponseAsync()).Deserialize<PersonInfo>(JsonSerializerOptions.Web);
// Invoke the agent with some unstructured input to extract the structured information from.
AgentResponse response = await agent.RunAsync("Provide information about the capital of France.");
Console.WriteLine("Assistant Output:");
Console.WriteLine($"Name: {personInfo.Name}");
Console.WriteLine($"Age: {personInfo.Age}");
Console.WriteLine($"Occupation: {personInfo.Occupation}");
// Access the structured output via the Text property of the agent response as JSON in scenarios when JSON as text is required
// and no object instance is needed (e.g., for logging, forwarding to another service, or storing in a database).
Console.WriteLine("Assistant Output (JSON):");
Console.WriteLine(response.Text);
Console.WriteLine();
// Deserialize the JSON text to work with the structured object in scenarios when you need to access properties,
// perform operations, or pass the data to methods that require the typed object instance.
CityInfo cityInfo = JsonSerializer.Deserialize<CityInfo>(response.Text)!;
Console.WriteLine("Assistant Output (Deserialized):");
Console.WriteLine($"Name: {cityInfo.Name}");
Console.WriteLine();
}
static async Task UseStructuredOutputWithRunAsync(ChatClient chatClient)
{
Console.WriteLine("=== Structured Output with RunAsync<T> ===");
// Create the agent
AIAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant.");
// Set CityInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke it with some unstructured input.
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>("Provide information about the capital of France.");
// Access the structured output via the Result property of the agent response.
CityInfo cityInfo = response.Result;
Console.WriteLine("Assistant Output:");
Console.WriteLine($"Name: {cityInfo.Name}");
Console.WriteLine();
}
static async Task UseStructuredOutputWithRunStreamingAsync(ChatClient chatClient)
{
Console.WriteLine("=== Structured Output with RunStreamingAsync ===");
// Create the agent
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
{
Name = "HelpfulAssistant",
ChatOptions = new()
{
Instructions = "You are a helpful assistant.",
// Specify CityInfo as the type parameter of ForJsonSchema to indicate the expected structured output from the agent.
ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema<CityInfo>()
}
});
// Invoke the agent with some unstructured input while streaming, to extract the structured information from.
IAsyncEnumerable<AgentResponseUpdate> updates = agent.RunStreamingAsync("Provide information about the capital of France.");
// Assemble all the parts of the streamed output.
AgentResponse nonGenericResponse = await updates.ToAgentResponseAsync();
// Access the structured output by deserializing JSON in the Text property.
CityInfo cityInfo = JsonSerializer.Deserialize<CityInfo>(nonGenericResponse.Text)!;
Console.WriteLine("Assistant Output:");
Console.WriteLine($"Name: {cityInfo.Name}");
Console.WriteLine();
}
static async Task UseStructuredOutputWithMiddlewareAsync(ChatClient chatClient)
{
Console.WriteLine("=== Structured Output with UseStructuredOutput Middleware ===");
// Create chat client that will transform the agent text response into structured output.
IChatClient meaiChatClient = chatClient.AsIChatClient();
// Create the agent
AIAgent agent = meaiChatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant.");
// Add structured output middleware via UseStructuredOutput method to add structured output support to the agent.
// This middleware transforms the agent's text response into structured data using a chat client.
// Since our agent does support structured output natively, we will add a middleware that removes ResponseFormat
// from the AgentRunOptions to emulate an agent that doesn't support structured output natively
agent = agent
.AsBuilder()
.UseStructuredOutput(meaiChatClient)
.Use(ResponseFormatRemovalMiddleware, null)
.Build();
// Set CityInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke it with some unstructured input.
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>("Provide information about the capital of France.");
// Access the structured output via the Result property of the agent response.
CityInfo cityInfo = response.Result;
Console.WriteLine("Assistant Output:");
Console.WriteLine($"Name: {cityInfo.Name}");
Console.WriteLine();
}
static Task<AgentResponse> ResponseFormatRemovalMiddleware(IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken)
{
// Remove any ResponseFormat from the options to emulate an agent that doesn't support structured output natively.
options = options?.Clone();
options?.ResponseFormat = null;
return innerAgent.RunAsync(messages, session, options, cancellationToken);
}
namespace SampleApp
{
/// <summary>
/// Represents information about a person, including their name, age, and occupation, matched to the JSON schema used in the agent.
/// Represents information about a city, including its name.
/// </summary>
[Description("Information about a person including their name, age, and occupation")]
public class PersonInfo
[Description("Information about a city")]
public sealed class CityInfo
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("age")]
public int? Age { get; set; }
[JsonPropertyName("occupation")]
public string? Occupation { get; set; }
}
}
@@ -0,0 +1,52 @@
# Structured Output with ChatClientAgent
This sample demonstrates how to configure ChatClientAgent to produce structured output in JSON format using various approaches.
## What this sample demonstrates
- **ResponseFormat approach**: Configuring agents with JSON schema response format via `ChatResponseFormat.ForJsonSchema<T>()` for inter-agent communication or when the type is not known at compile time
- **Generic RunAsync<T> method**: Using the generic `RunAsync<T>` method for structured output when the caller needs to work directly with typed objects
- **Structured output with Streaming**: Using `RunStreamingAsync` to stream responses while still obtaining structured output by assembling and deserializing the streamed content
- **StructuredOutput middleware**: Adding structured output support to agents that don't natively support it (like A2A agents or models without structured output capability) by transforming text output into structured data using a chat client
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10 SDK or later
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
**Note**: This sample uses Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai).
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
## Environment Variables
Set the following environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
```
## Run the sample
Navigate to the sample directory and run:
```powershell
cd dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput
dotnet run
```
## Expected behavior
The sample will demonstrate four different approaches to structured output:
1. **Structured Output with ResponseFormat**: Creates an agent with `ResponseFormat` set to `ForJsonSchema<CityInfo>()`, invokes it with unstructured input, and accesses the structured output via the `Text` property
2. **Structured Output with RunAsync<T>**: Creates an agent and uses the generic `RunAsync<CityInfo>()` method to get a typed `AgentResponse<CityInfo>` with the result accessible via the `Result` property
3. **Structured Output with RunStreamingAsync**: Creates an agent with JSON schema response format, streams the response using `RunStreamingAsync`, assembles the updates using `ToAgentResponseAsync()`, and deserializes the JSON text into a typed object
4. **Structured Output with StructuredOutput Middleware**: Uses the `UseStructuredOutput` method on `AIAgentBuilder` to add structured output support to agents that don't natively support it
Each approach will output information about the capital of France (Paris) in a structured format.
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace SampleApp;
/// <summary>
/// A delegating AI agent that converts text responses from an inner AI agent into structured output using a chat client.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="StructuredOutputAgent"/> wraps an inner agent and uses a chat client to transform
/// the inner agent's text response into a structured JSON format based on the specified response format.
/// </para>
/// <para>
/// This agent requires a <see cref="ChatResponseFormatJson"/> to be specified either through the
/// <see cref="AgentRunOptions.ResponseFormat"/> or the <see cref="StructuredOutputAgentOptions.ChatOptions"/>
/// provided during construction.
/// </para>
/// </remarks>
internal sealed class StructuredOutputAgent : DelegatingAIAgent
{
private readonly IChatClient _chatClient;
private readonly StructuredOutputAgentOptions? _agentOptions;
/// <summary>
/// Initializes a new instance of the <see cref="StructuredOutputAgent"/> class.
/// </summary>
/// <param name="innerAgent">The underlying agent that generates text responses to be converted to structured output.</param>
/// <param name="chatClient">The chat client used to transform text responses into structured JSON format.</param>
/// <param name="options">Optional configuration options for the structured output agent.</param>
public StructuredOutputAgent(AIAgent innerAgent, IChatClient chatClient, StructuredOutputAgentOptions? options = null)
: base(innerAgent)
{
this._chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient));
this._agentOptions = options;
}
/// <inheritdoc />
protected override async Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
// Run the inner agent first, to get back the text response we want to convert.
var textResponse = await this.InnerAgent.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
// Invoke the chat client to transform the text output into structured data.
ChatResponse soResponse = await this._chatClient.GetResponseAsync(
messages: this.GetChatMessages(textResponse.Text),
options: this.GetChatOptions(options),
cancellationToken: cancellationToken).ConfigureAwait(false);
return new StructuredOutputAgentResponse(soResponse, textResponse);
}
private List<ChatMessage> GetChatMessages(string? textResponseText)
{
List<ChatMessage> chatMessages = [];
if (this._agentOptions?.ChatClientSystemMessage is not null)
{
chatMessages.Add(new ChatMessage(ChatRole.System, this._agentOptions.ChatClientSystemMessage));
}
chatMessages.Add(new ChatMessage(ChatRole.User, textResponseText));
return chatMessages;
}
private ChatOptions GetChatOptions(AgentRunOptions? options)
{
ChatResponseFormat responseFormat = options?.ResponseFormat
?? this._agentOptions?.ChatOptions?.ResponseFormat
?? throw new InvalidOperationException($"A response format of type '{nameof(ChatResponseFormatJson)}' must be specified, but none was specified.");
if (responseFormat is not ChatResponseFormatJson jsonResponseFormat)
{
throw new NotSupportedException($"A response format of type '{nameof(ChatResponseFormatJson)}' must be specified, but was '{responseFormat.GetType().Name}'.");
}
var chatOptions = this._agentOptions?.ChatOptions?.Clone() ?? new ChatOptions();
chatOptions.ResponseFormat = jsonResponseFormat;
return chatOptions;
}
}
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace SampleApp;
/// <summary>
/// Represents configuration options for a <see cref="StructuredOutputAgent"/>.
/// </summary>
#pragma warning disable CA1812 // Instantiated via AIAgentBuilderExtensions.UseStructuredOutput optionsFactory parameter
internal sealed class StructuredOutputAgentOptions
#pragma warning restore CA1812
{
/// <summary>
/// Gets or sets the system message to use when invoking the chat client for structured output conversion.
/// </summary>
public string? ChatClientSystemMessage { get; set; }
/// <summary>
/// Gets or sets the chat options to use for the structured output conversion by the chat client
/// used by the agent.
/// </summary>
/// <remarks>
/// This property is optional. The <see cref="ChatOptions.ResponseFormat"/> should be set to a
/// <see cref="ChatResponseFormatJson"/> instance to specify the expected JSON schema for the structured output.
/// Note that if <see cref="AgentRunOptions.ResponseFormat"/> is provided when running the agent,
/// it will take precedence and override the <see cref="ChatOptions.ResponseFormat"/> specified here.
/// </remarks>
public ChatOptions? ChatOptions { get; set; }
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace SampleApp;
/// <summary>
/// Represents an agent response that contains structured output and
/// the original agent response from which the structured output was generated.
/// </summary>
internal sealed class StructuredOutputAgentResponse : AgentResponse
{
/// <summary>
/// Initializes a new instance of the <see cref="StructuredOutputAgentResponse"/> class.
/// </summary>
/// <param name="chatResponse">The <see cref="ChatResponse"/> containing the structured output.</param>
/// <param name="agentResponse">The original <see cref="AgentResponse"/> from the inner agent.</param>
public StructuredOutputAgentResponse(ChatResponse chatResponse, AgentResponse agentResponse) : base(chatResponse)
{
this.OriginalResponse = agentResponse;
}
/// <summary>
/// Gets the original non-structured response from the inner agent used by chat client to produce the structured output.
/// </summary>
public AgentResponse OriginalResponse { get; }
}
@@ -64,7 +64,8 @@ IAsyncEnumerable<AgentResponseUpdate> updates = agentWithPersonInfo.RunStreaming
// Assemble all the parts of the streamed output, since we can only deserialize once we have the full json,
// then deserialize the response into the PersonInfo class.
PersonInfo personInfo = (await updates.ToAgentResponseAsync()).Deserialize<PersonInfo>(JsonSerializerOptions.Web);
PersonInfo personInfo = JsonSerializer.Deserialize<PersonInfo>((await updates.ToAgentResponseAsync()).Text, JsonSerializerOptions.Web)
?? throw new InvalidOperationException("Failed to deserialize the streamed response into PersonInfo.");
Console.WriteLine("Assistant Output:");
Console.WriteLine($"Name: {personInfo.Name}");
@@ -49,7 +49,7 @@ public static class SampleWorkflowProvider
/// <summary>
/// Invokes an agent to process messages and return a response within a conversation context.
/// </summary>
internal sealed class QuestionStudentExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_student", session, agentProvider)
internal sealed class QuestionStudentExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExecutor(id: "question_student", session, agentProvider)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -86,7 +86,7 @@ public static class SampleWorkflowProvider
/// <summary>
/// Invokes an agent to process messages and return a response within a conversation context.
/// </summary>
internal sealed class QuestionTeacherExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_teacher", session, agentProvider)
internal sealed class QuestionTeacherExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExecutor(id: "question_teacher", session, agentProvider)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -67,7 +67,7 @@ internal sealed class Program
/// <summary>
/// Create the workflow from the declarative YAML. Includes definition of the
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="WorkflowAgentProvider"/>.
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
/// </summary>
private Workflow CreateWorkflow()
{
@@ -330,7 +330,8 @@ internal sealed class CriticExecutor : Executor<ChatMessage, CriticDecision>
// Convert the stream to a response and deserialize the structured output
AgentResponse response = await updates.ToAgentResponseAsync(cancellationToken);
CriticDecision decision = response.Deserialize<CriticDecision>(JsonSerializerOptions.Web);
CriticDecision decision = JsonSerializer.Deserialize<CriticDecision>(response.Text, JsonSerializerOptions.Web)
?? throw new JsonException("Failed to deserialize CriticDecision from response text.");
Console.WriteLine($"Decision: {(decision.Approved ? "✅ APPROVED" : "❌ NEEDS REVISION")}");
if (!string.IsNullOrEmpty(decision.Feedback))
@@ -54,7 +54,7 @@ public class WeatherForecastAgent : DelegatingAIAgent
// If the agent returned a valid structured output response
// we might be able to enhance the response with an adaptive card.
if (response.TryDeserialize<WeatherForecastAgentResponse>(JsonSerializerOptions.Web, out var structuredOutput))
if (TryDeserialize<WeatherForecastAgentResponse>(response.Text, JsonSerializerOptions.Web, out var structuredOutput))
{
var textContentMessage = response.Messages.FirstOrDefault(x => x.Contents.OfType<TextContent>().Any());
if (textContentMessage is not null)
@@ -112,4 +112,25 @@ public class WeatherForecastAgent : DelegatingAIAgent
});
return card;
}
private static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
{
try
{
T? result = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
if (result is null)
{
structuredOutput = default!;
return false;
}
structuredOutput = result;
return true;
}
catch
{
structuredOutput = default!;
return false;
}
}
}
@@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI;
/// may involve multiple agents working together.
/// </remarks>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public abstract class AIAgent
public abstract partial class AIAgent
{
private static readonly AsyncLocal<AgentRunContext?> s_currentContext = new();
@@ -11,155 +11,130 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an <see cref="AIAgent"/> that delegates to an <see cref="IChatClient"/> implementation.
/// Provides structured output methods for <see cref="AIAgent"/> that enable requesting responses in a specific type format.
/// </summary>
public sealed partial class ChatClientAgent
public abstract partial class AIAgent
{
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the session, and requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </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="AgentResponse"/> with the agent's output.</returns>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
/// <remarks>
/// This overload is useful when the agent has sufficient context from previous messages in the session
/// or from its initial configuration to generate a meaningful response without additional input.
/// </remarks>
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
public Task<AgentResponse<T>> RunAsync<T>(
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>([], session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken);
this.RunAsync<T>([], session, serializerOptions, options, cancellationToken);
/// <summary>
/// Runs the agent with a text message from the user, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="message">The user message to send to the agent.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </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="AgentResponse"/> with the agent's output.</returns>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
/// <exception cref="ArgumentException"><paramref name="message"/> is <see langword="null"/>, empty, or contains only whitespace.</exception>
/// <remarks>
/// The provided text will be wrapped in a <see cref="ChatMessage"/> with the <see cref="ChatRole.User"/> role
/// before being sent to the agent. This is a convenience method for simple text-based interactions.
/// </remarks>
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
public Task<AgentResponse<T>> RunAsync<T>(
string message,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNullOrWhitespace(message);
return this.RunAsync<T>(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken);
return this.RunAsync<T>(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken);
}
/// <summary>
/// Runs the agent with a single chat message, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="message">The chat message to send to the agent.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </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="AgentResponse"/> with the agent's output.</returns>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
public Task<AgentResponse<T>> RunAsync<T>(
ChatMessage message,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(message);
return this.RunAsync<T>([message], session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken);
return this.RunAsync<T>([message], session, serializerOptions, options, cancellationToken);
}
/// <summary>
/// Runs the agent with a collection of chat messages, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input messages and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </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="AgentResponse"/> with the agent's output.</returns>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> 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.
/// This method 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="session"/> if one is provided.
/// </para>
/// </remarks>
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
public async Task<AgentResponse<T>> RunAsync<T>(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default)
{
async Task<ChatResponse<T>> GetResponseAsync(IChatClient chatClient, List<ChatMessage> threadMessages, ChatOptions? chatOptions, CancellationToken ct)
{
return await chatClient.GetResponseAsync<T>(
threadMessages,
serializerOptions ?? AgentJsonUtilities.DefaultOptions,
chatOptions,
useJsonSchemaResponseFormat,
ct).ConfigureAwait(false);
}
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
static ChatClientAgentResponse<T> CreateResponse(ChatResponse<T> chatResponse)
{
return new ChatClientAgentResponse<T>(chatResponse)
{
ContinuationToken = WrapContinuationToken(chatResponse.ContinuationToken)
};
}
var responseFormat = ChatResponseFormat.ForJsonSchema<T>(serializerOptions);
return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, session, options, cancellationToken);
(responseFormat, bool isWrappedInObject) = StructuredOutputSchemaUtilities.WrapNonObjectSchema(responseFormat);
options = options?.Clone() ?? new AgentRunOptions();
options.ResponseFormat = responseFormat;
AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
return new AgentResponse<T>(response, serializerOptions) { IsWrappedInObject = isWrappedInObject };
}
}
@@ -1,20 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
#if NET
using System.Buffers;
#endif
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
#if NET
using System.Text;
#endif
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Shared.Diagnostics;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -76,6 +67,29 @@ public class AgentResponse
this.ContinuationToken = response.ContinuationToken;
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse"/> class from an existing <see cref="AgentResponse"/>.
/// </summary>
/// <param name="response">The <see cref="AgentResponse"/> from which to copy properties.</param>
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This constructor creates a copy of an existing agent response, preserving all
/// metadata and storing the original response in <see cref="RawRepresentation"/> for access to
/// the underlying implementation details.
/// </remarks>
protected AgentResponse(AgentResponse response)
{
_ = Throw.IfNull(response);
this.AdditionalProperties = response.AdditionalProperties;
this.CreatedAt = response.CreatedAt;
this.Messages = response.Messages;
this.RawRepresentation = response;
this.ResponseId = response.ResponseId;
this.Usage = response.Usage;
this.ContinuationToken = response.ContinuationToken;
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse"/> class with the specified collection of messages.
/// </summary>
@@ -274,117 +288,4 @@ public class AgentResponse
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>
/// <typeparam name="T">The output type to deserialize into.</typeparam>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <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>(JsonSerializerOptions serializerOptions)
{
_ = Throw.IfNull(serializerOptions);
var structuredOutput = this.GetResultCore<T>(serializerOptions, out var failureReason);
return failureReason switch
{
FailureReason.ResultDidNotContainJson => throw new InvalidOperationException("The response did not contain JSON to be deserialized."),
FailureReason.DeserializationProducedNull => throw new InvalidOperationException("The deserialized response is null."),
_ => structuredOutput!,
};
}
/// <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>
/// <typeparam name="T">The output type to deserialize into.</typeparam>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <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>(JsonSerializerOptions serializerOptions, [NotNullWhen(true)] out T? structuredOutput)
{
_ = Throw.IfNull(serializerOptions);
try
{
structuredOutput = this.GetResultCore<T>(serializerOptions, out var failureReason);
return failureReason is null;
}
catch
{
structuredOutput = default;
return false;
}
}
private static T? DeserializeFirstTopLevelObject<T>(string json, JsonTypeInfo<T> typeInfo)
{
#if NET
// We need to deserialize only the first top-level object as a workaround for a common LLM backend
// issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call.
// See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348
var utf8ByteLength = Encoding.UTF8.GetByteCount(json);
var buffer = ArrayPool<byte>.Shared.Rent(utf8ByteLength);
try
{
var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0);
var reader = new Utf8JsonReader(new ReadOnlySpan<byte>(buffer, 0, utf8SpanLength), new() { AllowMultipleValues = true });
return JsonSerializer.Deserialize(ref reader, typeInfo);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
#else
return JsonSerializer.Deserialize(json, typeInfo);
#endif
}
private T? GetResultCore<T>(JsonSerializerOptions serializerOptions, out FailureReason? failureReason)
{
var json = this.Text;
if (string.IsNullOrEmpty(json))
{
failureReason = FailureReason.ResultDidNotContainJson;
return default;
}
// If there's an exception here, we want it to propagate, since the Result property is meant to throw directly
T? deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo<T>)serializerOptions.GetTypeInfo(typeof(T)));
if (deserialized is null)
{
failureReason = FailureReason.DeserializationProducedNull;
return default;
}
failureReason = default;
return deserialized;
}
private enum FailureReason
{
ResultDidNotContainJson,
DeserializationProducedNull
}
}
@@ -1,6 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
using System;
#if NET
using System.Buffers;
#endif
#if NET
using System.Text;
#endif
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -8,23 +19,80 @@ namespace Microsoft.Agents.AI;
/// Represents the response of the specified type <typeparamref name="T"/> to an <see cref="AIAgent"/> run request.
/// </summary>
/// <typeparam name="T">The type of value expected from the agent.</typeparam>
public abstract class AgentResponse<T> : AgentResponse
public class AgentResponse<T> : AgentResponse
{
/// <summary>Initializes a new instance of the <see cref="AgentResponse{T}"/> class.</summary>
protected AgentResponse()
private readonly JsonSerializerOptions _serializerOptions;
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse{T}"/> class.
/// </summary>
/// <param name="response">The <see cref="AgentResponse"/> from which to populate this <see cref="AgentResponse{T}"/>.</param>
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> to use when deserializing the result.</param>
/// <exception cref="ArgumentNullException"><paramref name="serializerOptions"/> is <see langword="null"/>.</exception>
public AgentResponse(AgentResponse response, JsonSerializerOptions serializerOptions) : base(response)
{
_ = Throw.IfNull(serializerOptions);
this._serializerOptions = serializerOptions;
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse{T}"/> class from an existing <see cref="ChatResponse"/>.
/// Gets or sets a value indicating whether the JSON schema has an extra object wrapper.
/// </summary>
/// <param name="response">The <see cref="ChatResponse"/> from which to populate this <see cref="AgentResponse{T}"/>.</param>
protected AgentResponse(ChatResponse response) : base(response)
{
}
/// <remarks>
/// The wrapper is required for any non-JSON-object-typed values such as numbers, enum values, and arrays.
/// </remarks>
public bool IsWrappedInObject { get; init; }
/// <summary>
/// Gets the result value of the agent response as an instance of <typeparamref name="T"/>.
/// </summary>
public abstract T Result { get; }
[JsonIgnore]
public virtual T Result
{
get
{
var json = this.Text;
if (string.IsNullOrEmpty(json))
{
throw new InvalidOperationException("The response did not contain JSON to be deserialized.");
}
if (this.IsWrappedInObject)
{
json = StructuredOutputSchemaUtilities.UnwrapResponseData(json!);
}
T? deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo<T>)this._serializerOptions.GetTypeInfo(typeof(T)));
if (deserialized is null)
{
throw new InvalidOperationException("The deserialized response is null.");
}
return deserialized;
}
}
private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo<T> typeInfo)
{
#if NET
// We need to deserialize only the first top-level object as a workaround for a common LLM backend
// issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call.
// See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348
var utf8ByteLength = Encoding.UTF8.GetByteCount(json);
var buffer = ArrayPool<byte>.Shared.Rent(utf8ByteLength);
try
{
var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0);
var reader = new Utf8JsonReader(new ReadOnlySpan<byte>(buffer, 0, utf8SpanLength), new() { AllowMultipleValues = true });
return JsonSerializer.Deserialize(ref reader, typeInfo);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
#else
return JsonSerializer.Deserialize(json, typeInfo);
#endif
}
}
@@ -28,12 +28,13 @@ public class AgentRunOptions
/// </summary>
/// <param name="options">The options instance from which to copy values.</param>
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <see langword="null"/>.</exception>
public AgentRunOptions(AgentRunOptions options)
protected AgentRunOptions(AgentRunOptions options)
{
_ = Throw.IfNull(options);
this.ContinuationToken = options.ContinuationToken;
this.AllowBackgroundResponses = options.AllowBackgroundResponses;
this.AdditionalProperties = options.AdditionalProperties?.Clone();
this.ResponseFormat = options.ResponseFormat;
}
/// <summary>
@@ -90,4 +91,35 @@ public class AgentRunOptions
/// preserving implementation-specific details or extending the options with custom data.
/// </remarks>
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
/// <summary>
/// Gets or sets the response format.
/// </summary>
/// <remarks>
/// If <see langword="null"/>, no response format is specified and the agent will use its default.
/// This property can be set to <see cref="ChatResponseFormat.Text"/> to specify that the response should be unstructured text,
/// to <see cref="ChatResponseFormat.Json"/> to specify that the response should be structured JSON data, or
/// an instance of <see cref="ChatResponseFormatJson"/> constructed with a specific JSON schema to request that the
/// response be structured JSON data according to that schema. It is up to the agent implementation if or how
/// to honor the request. If the agent implementation doesn't recognize the specific kind of <see cref="ChatResponseFormat"/>,
/// it can be ignored.
/// </remarks>
public ChatResponseFormat? ResponseFormat { get; set; }
/// <summary>
/// Produces a clone of the current <see cref="AgentRunOptions"/> instance.
/// </summary>
/// <returns>
/// A clone of the current <see cref="AgentRunOptions"/> instance.
/// </returns>
/// <remarks>
/// <para>
/// The clone will have the same values for all properties as the original instance. Any collections, like <see cref="AdditionalProperties"/>,
/// are shallow-cloned, meaning a new collection instance is created, but any references contained by the collections are shared with the original.
/// </para>
/// <para>
/// Derived types should override <see cref="Clone"/> to return an instance of the derived type.
/// </para>
/// </remarks>
public virtual AgentRunOptions Clone() => new(this);
}
@@ -8,6 +8,7 @@
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedStructuredOutput>true</InjectSharedStructuredOutput>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
@@ -13,6 +13,7 @@
- Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699))
- Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879))
- Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
## v1.0.0-preview.251204.1
@@ -1,12 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Entities;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.DurableTask;
@@ -114,7 +113,6 @@ public sealed class DurableAIAgent : AIAgent
{
enableToolCalls = durableOptions.EnableToolCalls;
enableToolNames = durableOptions.EnableToolNames;
responseFormat = durableOptions.ResponseFormat;
}
else if (options is ChatClientAgentRunOptions chatClientOptions && chatClientOptions.ChatOptions?.Tools != null)
{
@@ -122,6 +120,12 @@ public sealed class DurableAIAgent : AIAgent
responseFormat = chatClientOptions.ChatOptions?.ResponseFormat;
}
// Override the response format if specified in the agent run options
if (options?.ResponseFormat is { } format)
{
responseFormat = format;
}
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames)
{
OrchestrationId = this._context.InstanceId
@@ -168,108 +172,125 @@ public sealed class DurableAIAgent : AIAgent
}
/// <summary>
/// Runs the agent with a message and returns the deserialized output as an instance of <typeparamref name="T"/>.
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the session, and requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <param name="message">The message to send to the agent.</param>
/// <param name="session">The agent session to use.</param>
/// <param name="serializerOptions">Optional JSON serializer options.</param>
/// <param name="options">Optional run options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the output.</typeparam>
/// <exception cref="ArgumentException">
/// Thrown when the provided <paramref name="options"/> already contains a response schema.
/// Thrown when the provided <paramref name="options"/> is not a <see cref="DurableAgentRunOptions"/>.
/// </exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the agent response is empty or cannot be deserialized.
/// </exception>
/// <returns>The output from the agent.</returns>
public async Task<AgentResponse<T>> RunAsync<T>(
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</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="AgentResponse{T}"/> with the agent's output.</returns>
/// <remarks>
/// This method is specific to durable agents because the Durable Task Framework uses a custom
/// synchronization context for orchestration execution, and all continuations must run on the
/// orchestration thread to avoid breaking the durable orchestration and potential deadlocks.
/// </remarks>
public new Task<AgentResponse<T>> RunAsync<T>(
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>([], session, serializerOptions, options, cancellationToken);
/// <summary>
/// Runs the agent with a text message from the user, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="message">The user message to send to the agent.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</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="AgentResponse{T}"/> with the agent's output.</returns>
/// <exception cref="ArgumentException"><paramref name="message"/> is <see langword="null"/>, empty, or contains only whitespace.</exception>
/// <remarks>
/// <inheritdoc cref="RunAsync{T}(AgentSession?, JsonSerializerOptions?, AgentRunOptions?, CancellationToken)" path="/remarks" />
/// </remarks>
public new Task<AgentResponse<T>> RunAsync<T>(
string message,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return await this.RunAsync<T>(
messages: [new ChatMessage(ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }],
session,
serializerOptions,
options,
cancellationToken);
_ = Throw.IfNull(message);
return this.RunAsync<T>(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken);
}
/// <summary>
/// Runs the agent with messages and returns the deserialized output as an instance of <typeparamref name="T"/>.
/// Runs the agent with a single chat message, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <param name="messages">The messages to send to the agent.</param>
/// <param name="session">The agent session to use.</param>
/// <param name="serializerOptions">Optional JSON serializer options.</param>
/// <param name="options">Optional run options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the output.</typeparam>
/// <exception cref="ArgumentException">
/// Thrown when the provided <paramref name="options"/> already contains a response schema.
/// Thrown when the provided <paramref name="options"/> is not a <see cref="DurableAgentRunOptions"/>.
/// </exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the agent response is empty or cannot be deserialized.
/// </exception>
/// <returns>The output from the agent.</returns>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback to reflection-based deserialization is intentional for library flexibility with user-defined types.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback to reflection-based deserialization is intentional for library flexibility with user-defined types.")]
public async Task<AgentResponse<T>> RunAsync<T>(
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="message">The chat message to send to the agent.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</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="AgentResponse{T}"/> with the agent's output.</returns>
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <inheritdoc cref="RunAsync{T}(AgentSession?, JsonSerializerOptions?, AgentRunOptions?, CancellationToken)" path="/remarks" />
/// </remarks>
public new Task<AgentResponse<T>> RunAsync<T>(
ChatMessage message,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(message);
return this.RunAsync<T>([message], session, serializerOptions, options, cancellationToken);
}
/// <summary>
/// Runs the agent with a collection of chat messages, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input messages and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</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="AgentResponse{T}"/> with the agent's output.</returns>
/// <remarks>
/// <inheritdoc cref="RunAsync{T}(AgentSession?, JsonSerializerOptions?, AgentRunOptions?, CancellationToken)" path="/remarks" />
/// </remarks>
public new async Task<AgentResponse<T>> RunAsync<T>(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
options ??= new DurableAgentRunOptions();
if (options is not DurableAgentRunOptions durableOptions)
{
throw new ArgumentException(
"Response schema is only supported with DurableAgentRunOptions when using durable agents. " +
"Cannot specify a response schema when calling RunAsync<T>.",
paramName: nameof(options));
}
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
if (durableOptions.ResponseFormat is not null)
{
throw new ArgumentException(
"A response schema is already defined in the provided DurableAgentRunOptions. " +
"Cannot specify a response schema when calling RunAsync<T>.",
paramName: nameof(options));
}
var responseFormat = ChatResponseFormat.ForJsonSchema<T>(serializerOptions);
// Create the JSON schema for the response type
durableOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<T>();
(responseFormat, bool isWrappedInObject) = StructuredOutputSchemaUtilities.WrapNonObjectSchema(responseFormat);
AgentResponse response = await this.RunAsync(messages, session, durableOptions, cancellationToken);
options = options?.Clone() ?? new DurableAgentRunOptions();
options.ResponseFormat = responseFormat;
// Deserialize the response text to the requested type
if (string.IsNullOrEmpty(response.Text))
{
throw new InvalidOperationException("Agent response is empty and cannot be deserialized.");
}
// ConfigureAwait(false) cannot be used here because the Durable Task Framework uses
// a custom synchronization context that requires all continuations to execute on the
// orchestration thread. Scheduling the continuation on an arbitrary thread would break
// the orchestration.
AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken);
serializerOptions ??= DurableAgentJsonUtilities.DefaultOptions;
// Prefer source-generated metadata when available to support AOT/trimming scenarios.
// Fallback to reflection-based deserialization for types without source-generated metadata.
// This is necessary since T is a user-provided type that may not have [JsonSerializable] coverage.
JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(typeof(T));
T? result = (typeInfo is JsonTypeInfo typedInfo
? (T?)JsonSerializer.Deserialize(response.Text, typedInfo)
: JsonSerializer.Deserialize<T>(response.Text, serializerOptions))
?? throw new InvalidOperationException($"Failed to deserialize agent response to type {typeof(T).Name}.");
return new DurableAIAgentResponse<T>(response, result);
}
private sealed class DurableAIAgentResponse<T>(AgentResponse response, T result)
: AgentResponse<T>(response.AsChatResponse())
{
public override T Result { get; } = result;
return new AgentResponse<T>(response, serializerOptions) { IsWrappedInObject = isWrappedInObject };
}
}
@@ -62,7 +62,6 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
{
enableToolCalls = durableOptions.EnableToolCalls;
enableToolNames = durableOptions.EnableToolNames;
responseFormat = durableOptions.ResponseFormat;
isFireAndForget = durableOptions.IsFireAndForget;
}
else if (options is ChatClientAgentRunOptions chatClientOptions)
@@ -71,6 +70,12 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
responseFormat = chatClientOptions.ChatOptions?.ResponseFormat;
}
// Override the response format if specified in the agent run options
if (options?.ResponseFormat is { } format)
{
responseFormat = format;
}
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames);
AgentSessionId sessionId = durableSession.SessionId;
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
@@ -9,6 +7,25 @@ namespace Microsoft.Agents.AI.DurableTask;
/// </summary>
public sealed class DurableAgentRunOptions : AgentRunOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableAgentRunOptions"/> class.
/// </summary>
public DurableAgentRunOptions()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DurableAgentRunOptions"/> class by copying values from the specified options.
/// </summary>
/// <param name="options">The options instance from which to copy values.</param>
private DurableAgentRunOptions(DurableAgentRunOptions options)
: base(options)
{
this.EnableToolCalls = options.EnableToolCalls;
this.EnableToolNames = options.EnableToolNames is not null ? new List<string>(options.EnableToolNames) : null;
this.IsFireAndForget = options.IsFireAndForget;
}
/// <summary>
/// Gets or sets whether to enable tool calls for this request.
/// </summary>
@@ -19,11 +36,6 @@ public sealed class DurableAgentRunOptions : AgentRunOptions
/// </summary>
public IList<string>? EnableToolNames { get; set; }
/// <summary>
/// Gets or sets the response format for the agent's response.
/// </summary>
public ChatResponseFormat? ResponseFormat { get; set; }
/// <summary>
/// Gets or sets whether to fire and forget the agent run request.
/// </summary>
@@ -33,4 +45,7 @@ public sealed class DurableAgentRunOptions : AgentRunOptions
/// long-running tasks where the caller does not need to wait for the agent to complete the run.
/// </remarks>
public bool IsFireAndForget { get; set; }
/// <inheritdoc/>
public override AgentRunOptions Clone() => new DurableAgentRunOptions(this);
}
@@ -17,6 +17,11 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedStructuredOutput>true</InjectSharedStructuredOutput>
</PropertyGroup>
<!-- Durable Task dependencies -->
<ItemGroup>
<PackageReference Include="Microsoft.DurableTask.Client" />
@@ -25,7 +25,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative;
/// project endpoint and credentials to authenticate requests.</remarks>
/// <param name="projectEndpoint">A <see cref="Uri"/> instance representing the endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project.</param>
/// <param name="projectCredentials">The credentials used to authenticate with the Foundry project. This must be a valid instance of <see cref="TokenCredential"/>.</param>
public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential projectCredentials) : WorkflowAgentProvider
public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential projectCredentials) : ResponseAgentProvider
{
private readonly Dictionary<string, AgentVersion> _versionCache = [];
private readonly Dictionary<string, AIAgent> _agentCache = [];
@@ -35,7 +35,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen
this.Write("\n/// <summary>\n/// Adds a new message to the specified agent conversation\n/// </s" +
"ummary>\ninternal sealed class ");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Name));
this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExe" +
this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExe" +
"cutor(id: \"");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Id));
this.Write("\", session)\n{\n // <inheritdoc />\n protected override async ValueTask<object" +
@@ -10,7 +10,7 @@
/// <summary>
/// Adds a new message to the specified agent conversation
/// </summary>
internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session)
internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -34,7 +34,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen
this.Write("\n/// <summary>\n/// Copies one or more messages into the specified agent conversat" +
"ion.\n/// </summary>\ninternal sealed class ");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Name));
this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExe" +
this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExe" +
"cutor(id: \"");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Id));
this.Write("\", session)\n{\n // <inheritdoc />\n protected override async ValueTask<object" +
@@ -9,7 +9,7 @@
/// <summary>
/// Copies one or more messages into the specified agent conversation.
/// </summary>
internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session)
internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -32,7 +32,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen
this.Write(this.ToStringHelper.ToStringWithCulture(this.Model.ConversationId));
this.Write("\" variable.\n/// </summary>\ninternal sealed class ");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Name));
this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExe" +
this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExe" +
"cutor(id: \"");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Id));
this.Write(@""", session)
@@ -6,7 +6,7 @@
/// <summary>
/// Creates a new conversation and stores the identifier value to the "<#= this.Model.ConversationId #>" variable.
/// </summary>
internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session)
internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session)
{
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
@@ -39,7 +39,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen
this.Write("\n/// <summary>\n/// Invokes an agent to process messages and return a response wit" +
"hin a conversation context.\n/// </summary>\ninternal sealed class ");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Name));
this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExec" +
this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExec" +
"utor(id: \"");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Id));
this.Write("\", session, agentProvider)\n{\n // <inheritdoc />\n protected override async V" +
@@ -12,7 +12,7 @@
/// <summary>
/// Invokes an agent to process messages and return a response within a conversation context.
/// </summary>
internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "<#= this.Id #>", session, agentProvider)
internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExecutor(id: "<#= this.Id #>", session, agentProvider)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -32,7 +32,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen
this.Write("\n/// <summary>\n/// Retrieves a list of messages from an agent conversation.\n/// <" +
"/summary>\ninternal sealed class ");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Name));
this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExe" +
this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExe" +
"cutor(id: \"");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Id));
this.Write("\", session)\n{\n // <inheritdoc />\n protected override async ValueTask<object" +
@@ -8,7 +8,7 @@
/// <summary>
/// Retrieves a list of messages from an agent conversation.
/// </summary>
internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session)
internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -38,7 +38,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen
this.Write("\n/// <summary>\n/// Retrieves a specific message from an agent conversation.\n/// <" +
"/summary>\ninternal sealed class ");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Name));
this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExe" +
this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExe" +
"cutor(id: \"");
this.Write(this.ToStringHelper.ToStringWithCulture(this.Id));
this.Write("\", session)\n{\n // <inheritdoc />\n protected override async ValueTask<object" +
@@ -12,7 +12,7 @@
/// <summary>
/// Retrieves a specific message from an agent conversation.
/// </summary>
internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session)
internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -13,12 +13,12 @@ namespace Microsoft.Agents.AI.Workflows.Declarative;
/// <summary>
/// Configuration options for workflow execution.
/// </summary>
public sealed class DeclarativeWorkflowOptions(WorkflowAgentProvider agentProvider)
public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvider)
{
/// <summary>
/// Defines the agent provider.
/// </summary>
public WorkflowAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider);
public ResponseAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider);
/// <summary>
/// Defines the configuration settings for the workflow.
@@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
internal static class AgentProviderExtensions
{
public static async ValueTask<AgentResponse> InvokeAgentAsync(
this WorkflowAgentProvider agentProvider,
this ResponseAgentProvider agentProvider,
string executorId,
IWorkflowContext context,
string agentName,
@@ -14,10 +14,10 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Kit;
/// <param name="id">The executor id</param>
/// <param name="session">Session to support formula expressions.</param>
/// <param name="agentProvider">Provider for accessing and manipulating agents and conversations.</param>
public abstract class AgentExecutor(string id, FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id, session)
public abstract class AgentExecutor(string id, FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id, session)
{
/// <summary>
/// Invokes an agent using the provided <see cref="WorkflowAgentProvider"/>.
/// Invokes an agent using the provided <see cref="ResponseAgentProvider"/>.
/// </summary>
/// <param name="context">The workflow execution context providing messaging and state services.</param>
/// <param name="agentName">The name or identifier of the agent.</param>
@@ -18,7 +18,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Kit;
public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecutor where TInput : notnull
{
private readonly IConfiguration? _configuration;
private readonly WorkflowAgentProvider _agentProvider;
private readonly ResponseAgentProvider _agentProvider;
private readonly WorkflowFormulaState _state;
private readonly Func<TInput, ChatMessage>? _inputTransform;
@@ -12,7 +12,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
internal sealed class AddConversationMessageExecutor(AddConversationMessage model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) :
internal sealed class AddConversationMessageExecutor(AddConversationMessage model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<AddConversationMessage>(model, state)
{
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
@@ -13,7 +13,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) :
internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<CopyConversationMessages>(model, state)
{
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
@@ -11,7 +11,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
internal sealed class CreateConversationExecutor(CreateConversation model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) :
internal sealed class CreateConversationExecutor(CreateConversation model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<CreateConversation>(model, state)
{
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
@@ -17,7 +17,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) :
internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeAzureAgent>(model, state)
{
public static class Steps
@@ -16,7 +16,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
internal sealed class QuestionExecutor(Question model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) :
internal sealed class QuestionExecutor(Question model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<Question>(model, state)
{
public static class Steps
@@ -12,7 +12,7 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
internal sealed class RequestExternalInputExecutor(RequestExternalInput model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state)
internal sealed class RequestExternalInputExecutor(RequestExternalInput model, ResponseAgentProvider agentProvider, WorkflowFormulaState state)
: DeclarativeActionExecutor<RequestExternalInput>(model, state)
{
public static class Steps
@@ -11,7 +11,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
internal sealed class RetrieveConversationMessageExecutor(RetrieveConversationMessage model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) :
internal sealed class RetrieveConversationMessageExecutor(RetrieveConversationMessage model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<RetrieveConversationMessage>(model, state)
{
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
@@ -13,7 +13,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
internal sealed class RetrieveConversationMessagesExecutor(RetrieveConversationMessages model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) :
internal sealed class RetrieveConversationMessagesExecutor(RetrieveConversationMessages model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<RetrieveConversationMessages>(model, state)
{
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
@@ -11,9 +11,15 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative;
/// <summary>
/// Base class for workflow agent providers.
/// Defines contract used by declarative workflow actions to invoke and manipulate agents and conversations.
/// </summary>
public abstract class WorkflowAgentProvider
/// <remarks>
/// The shape of this provider contract is very much opinionated around patterns that exist in the Open AI Responses API.
/// In addition to direct usage of the Responses API, Foundry V2 agents are supported as they are fundamentally based on
/// the Open AI Responses API. Using other <see cref="AIAgent"/> or <see cref="ChatClientAgent"/> patterns that are not
/// based on the Response API is currently not supported.
/// </remarks>
public abstract class ResponseAgentProvider
{
/// <summary>
/// Gets or sets a collection of additional tools an agent is able to automatically invoke.
@@ -612,6 +612,12 @@ public sealed partial class ChatClientAgent : AIAgent
chatOptions.AllowBackgroundResponses = agentRunOptions.AllowBackgroundResponses;
}
if (agentRunOptions?.ResponseFormat is not null)
{
chatOptions ??= new ChatOptions();
chatOptions.ResponseFormat = agentRunOptions.ResponseFormat;
}
ChatClientAgentContinuationToken? agentContinuationToken = null;
if ((agentRunOptions?.ContinuationToken ?? chatOptions?.ContinuationToken) is { } continuationToken)
@@ -162,19 +162,14 @@ public partial class ChatClientAgent
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </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="ChatClientAgentResponse{T}"/> with the agent's output.</returns>
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
public Task<AgentResponse<T>> RunAsync<T>(
AgentSession? session,
JsonSerializerOptions? serializerOptions,
ChatClientAgentRunOptions? options,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>(session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
this.RunAsync<T>(session, serializerOptions, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Runs the agent with a text message from the user, requesting a response of the specified type <typeparamref name="T"/>.
@@ -186,20 +181,15 @@ public partial class ChatClientAgent
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </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="ChatClientAgentResponse{T}"/> with the agent's output.</returns>
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
public Task<AgentResponse<T>> RunAsync<T>(
string message,
AgentSession? session,
JsonSerializerOptions? serializerOptions,
ChatClientAgentRunOptions? options,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>(message, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
this.RunAsync<T>(message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Runs the agent with a single chat message, requesting a response of the specified type <typeparamref name="T"/>.
@@ -211,20 +201,15 @@ public partial class ChatClientAgent
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </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="ChatClientAgentResponse{T}"/> with the agent's output.</returns>
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
public Task<AgentResponse<T>> RunAsync<T>(
ChatMessage message,
AgentSession? session,
JsonSerializerOptions? serializerOptions,
ChatClientAgentRunOptions? options,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>(message, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
this.RunAsync<T>(message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Runs the agent with a collection of chat messages, requesting a response of the specified type <typeparamref name="T"/>.
@@ -236,18 +221,13 @@ public partial class ChatClientAgent
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </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="ChatClientAgentResponse{T}"/> with the agent's output.</returns>
public Task<ChatClientAgentResponse<T>> RunAsync<T>(
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
public Task<AgentResponse<T>> RunAsync<T>(
IEnumerable<ChatMessage> messages,
AgentSession? session,
JsonSerializerOptions? serializerOptions,
ChatClientAgentRunOptions? options,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>(messages, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
this.RunAsync<T>(messages, session, serializerOptions, (AgentRunOptions?)options, cancellationToken);
}
@@ -26,6 +26,17 @@ public sealed class ChatClientAgentRunOptions : AgentRunOptions
this.ChatOptions = chatOptions;
}
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentRunOptions"/> class by copying values from the specified options.
/// </summary>
/// <param name="options">The options instance from which to copy values.</param>
private ChatClientAgentRunOptions(ChatClientAgentRunOptions options)
: base(options)
{
this.ChatOptions = options.ChatOptions?.Clone();
this.ChatClientFactory = options.ChatClientFactory;
}
/// <summary>
/// Gets or sets the chat options to apply to the agent invocation.
/// </summary>
@@ -50,4 +61,7 @@ public sealed class ChatClientAgentRunOptions : AgentRunOptions
/// chat client will be used without modification.
/// </value>
public Func<IChatClient, IChatClient>? ChatClientFactory { get; set; }
/// <inheritdoc/>
public override AgentRunOptions Clone() => new ChatClientAgentRunOptions(this);
}
@@ -1,45 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the response of the specified type <typeparamref name="T"/> to an <see cref="ChatClientAgent"/> run request.
/// </summary>
/// <typeparam name="T">The type of value expected from the chat response.</typeparam>
/// <remarks>
/// Language models are not guaranteed to honor the requested schema. If the model's output is not
/// parsable as the expected type, you can access the underlying JSON response on the <see cref="AgentResponse.Text"/> property.
/// </remarks>
public sealed class ChatClientAgentResponse<T> : AgentResponse<T>
{
private readonly ChatResponse<T> _response;
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse{T}"/> class from an existing <see cref="ChatResponse{T}"/>.
/// </summary>
/// <param name="response">The <see cref="ChatResponse{T}"/> from which to populate this <see cref="AgentResponse{T}"/>.</param>
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This constructor creates an agent response that wraps an existing <see cref="ChatResponse{T}"/>, preserving all
/// metadata and storing the original response in <see cref="ChatResponse.RawRepresentation"/> for access to
/// the underlying implementation details.
/// </remarks>
public ChatClientAgentResponse(ChatResponse<T> response) : base(response)
{
_ = Throw.IfNull(response);
this._response = response;
}
/// <summary>
/// Gets the result value of the agent response as an instance of <typeparamref name="T"/>.
/// </summary>
/// <remarks>
/// If the response did not contain JSON, or if deserialization fails, this property will throw.
/// </remarks>
public override T Result => this._response.Result;
}
@@ -0,0 +1,104 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable IDE0005 // Using directive is unnecessary.
using System;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Internal utilities for working with structured output JSON schemas.
/// </summary>
internal static class StructuredOutputSchemaUtilities
{
private const string DataPropertyName = "data";
/// <summary>
/// Ensures the given response format has an object schema at the root, wrapping non-object schemas if necessary.
/// </summary>
/// <param name="responseFormat">The response format to check.</param>
/// <returns>A tuple containing the (possibly wrapped) response format and whether wrapping occurred.</returns>
/// <exception cref="InvalidOperationException">The response format does not have a valid JSON schema.</exception>
internal static (ChatResponseFormatJson ResponseFormat, bool IsWrappedInObject) WrapNonObjectSchema(ChatResponseFormatJson responseFormat)
{
if (responseFormat.Schema is null)
{
throw new InvalidOperationException("The response format must have a valid JSON schema.");
}
var schema = responseFormat.Schema.Value;
bool isWrappedInObject = false;
if (!SchemaRepresentsObject(responseFormat.Schema))
{
// For non-object-representing schemas, we wrap them in an object schema, because all
// the real LLM providers today require an object schema as the root. This is currently
// true even for providers that support native structured output.
isWrappedInObject = true;
schema = JsonSerializer.SerializeToElement(new JsonObject
{
{ "$schema", "https://json-schema.org/draft/2020-12/schema" },
{ "type", "object" },
{ "properties", new JsonObject { { DataPropertyName, JsonElementToJsonNode(schema) } } },
{ "additionalProperties", false },
{ "required", new JsonArray(DataPropertyName) },
}, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonObject)));
responseFormat = ChatResponseFormat.ForJsonSchema(schema, responseFormat.SchemaName, responseFormat.SchemaDescription);
}
return (responseFormat, isWrappedInObject);
}
/// <summary>
/// Unwraps the <c>"data"</c> property from a JSON object that was previously wrapped by <see cref="WrapNonObjectSchema"/>.
/// </summary>
/// <param name="json">The JSON string to unwrap.</param>
/// <returns>The raw JSON text of the <c>"data"</c> property, or the original JSON if no wrapping is detected.</returns>
internal static string UnwrapResponseData(string json)
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind == JsonValueKind.Object &&
document.RootElement.TryGetProperty(DataPropertyName, out JsonElement dataElement))
{
return dataElement.GetRawText();
}
// If root is not an object or "data" property is not found, return the original JSON as a fallback
return json;
}
private static bool SchemaRepresentsObject(JsonElement? schema)
{
if (schema is not { } schemaElement)
{
return false;
}
if (schemaElement.ValueKind is JsonValueKind.Object)
{
foreach (var property in schemaElement.EnumerateObject())
{
if (property.NameEquals("type"u8))
{
return property.Value.ValueKind == JsonValueKind.String
&& property.Value.ValueEquals("object"u8);
}
}
}
return false;
}
private static JsonNode? JsonElementToJsonNode(JsonElement element) =>
element.ValueKind switch
{
JsonValueKind.Null => null,
JsonValueKind.Array => JsonArray.Create(element),
JsonValueKind.Object => JsonObject.Create(element),
_ => JsonValue.Create(element)
};
}
@@ -24,7 +24,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
/// <summary>
/// Create the workflow from the declarative YAML. Includes definition of the
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="WorkflowAgentProvider"/>.
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
/// </summary>
public Workflow CreateWorkflow()
{
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConformance.IntegrationTests;
/// <summary>
/// Conformance tests for structured output handling for run methods on agents.
/// </summary>
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
public abstract class StructuredOutputRunTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
where TAgentFixture : IAgentFixture
{
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithResponseFormatReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
var options = new AgentRunOptions
{
ResponseFormat = ChatResponseFormat.ForJsonSchema<CityInfo>(AgentAbstractionsJsonUtilities.DefaultOptions)
};
// Act
var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session, options);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo));
Assert.Equal("Paris", cityInfo.Name);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithGenericTypeReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>(
new ChatMessage(ChatRole.User, "Provide information about the capital of France."),
session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.NotNull(response.Result);
Assert.Equal("Paris", response.Result.Name);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithPrimitiveTypeReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act - Request a primitive type, which requires wrapping in an object schema
AgentResponse<int> response = await agent.RunAsync<int>(
new ChatMessage(ChatRole.User, "What is the sum of 15 and 27? Respond with just the number."),
session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Equal(42, response.Result);
}
protected static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
{
try
{
T? deserialized = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
if (deserialized is null)
{
structuredOutput = default!;
return false;
}
structuredOutput = deserialized;
return true;
}
catch
{
structuredOutput = default!;
return false;
}
}
}
public sealed class CityInfo
{
public string? Name { get; set; }
}
@@ -2,7 +2,7 @@
namespace AgentConformance.IntegrationTests.Support;
internal static class Constants
public static class Constants
{
public const int RetryCount = 3;
public const int RetryDelay = 5000;
@@ -11,7 +11,7 @@ namespace AgentConformance.IntegrationTests.Support;
/// </summary>
/// <param name="session">The session to delete.</param>
/// <param name="fixture">The fixture that provides agent specific capabilities.</param>
internal sealed class SessionCleanup(AgentSession session, IAgentFixture fixture) : IAsyncDisposable
public sealed class SessionCleanup(AgentSession session, IAgentFixture fixture) : IAsyncDisposable
{
public async ValueTask DisposeAsync() =>
await fixture.DeleteSessionAsync(session);
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AzureAI.IntegrationTests;
public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRunTests<AIProjectClientStructuredOutputFixture<CityInfo>>(() => new AIProjectClientStructuredOutputFixture<CityInfo>())
{
private const string NotSupported = "AIProjectClient does not support specifying structured output type at invocation time.";
/// <summary>
/// Verifies that response format provided at agent initialization is used when invoking RunAsync.
/// </summary>
/// <returns></returns>
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo));
Assert.Equal("Paris", cityInfo.Name);
}
/// <summary>
/// Verifies that generic RunAsync works with AIProjectClient when structured output is configured at agent initialization.
/// </summary>
/// <remarks>
/// AIProjectClient does not support specifying the structured output type at invocation time yet.
/// The type T provided to RunAsync&lt;T&gt; is ignored by AzureAIProjectChatClient and is only used
/// for deserializing the agent response by AgentResponse&lt;T&gt;.Result.
/// </remarks>
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>(
new ChatMessage(ChatRole.User, "Provide information about the capital of France."),
session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.NotNull(response.Result);
Assert.Equal("Paris", response.Result.Name);
}
[Fact(Skip = NotSupported)]
public override Task RunWithGenericTypeReturnsExpectedResultAsync() =>
base.RunWithGenericTypeReturnsExpectedResultAsync();
[Fact(Skip = NotSupported)]
public override Task RunWithResponseFormatReturnsExpectedResultAsync() =>
base.RunWithResponseFormatReturnsExpectedResultAsync();
[Fact(Skip = NotSupported)]
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync() =>
base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
}
/// <summary>
/// Represents a fixture for testing AIProjectClient with structured output of type <typeparamref name="T"/> provided at agent initialization.
/// </summary>
public class AIProjectClientStructuredOutputFixture<T> : AIProjectClientFixture
{
public override Task InitializeAsync()
{
var agentOptions = new ChatClientAgentOptions
{
ChatOptions = new ChatOptions()
{
ResponseFormat = ChatResponseFormat.ForJsonSchema<T>(AgentAbstractionsJsonUtilities.DefaultOptions)
},
};
return this.InitializeAsync(agentOptions);
}
}
@@ -121,6 +121,13 @@ public class AIProjectClientFixture : IChatClientAgentFixture
return await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: s_config.DeploymentName, instructions: instructions, tools: aiTools);
}
public async Task<ChatClientAgent> CreateChatClientAgentAsync(ChatClientAgentOptions options)
{
options.Name ??= GenerateUniqueAgentName("HelpfulAssistant");
return await this._client.CreateAIAgentAsync(model: s_config.DeploymentName, options);
}
public static string GenerateUniqueAgentName(string baseName) =>
$"{baseName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
@@ -161,9 +168,15 @@ public class AIProjectClientFixture : IChatClientAgentFixture
return Task.CompletedTask;
}
public async Task InitializeAsync()
public virtual async Task InitializeAsync()
{
this._client = new(new Uri(s_config.Endpoint), new AzureCliCredential());
this._agent = await this.CreateChatClientAgentAsync();
}
public async Task InitializeAsync(ChatClientAgentOptions options)
{
this._client = new(new Uri(s_config.Endpoint), new AzureCliCredential());
this._agent = await this.CreateChatClientAgentAsync(options);
}
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace AzureAIAgentsPersistent.IntegrationTests;
public class AzureAIAgentsPersistentStructuredOutputRunTests() : StructuredOutputRunTests<AzureAIAgentsPersistentFixture>(() => new())
{
[Fact(Skip = "Fails intermittently, at build agent")]
public override Task RunWithResponseFormatReturnsExpectedResultAsync() =>
base.RunWithResponseFormatReturnsExpectedResultAsync();
}
@@ -0,0 +1,391 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Abstractions.UnitTests.Models;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
/// <summary>
/// Unit tests for the structured output functionality in <see cref="AIAgent"/>.
/// </summary>
public class AIAgentStructuredOutputTests
{
private readonly Mock<AIAgent> _agentMock;
public AIAgentStructuredOutputTests()
{
this._agentMock = new Mock<AIAgent> { CallBase = true };
}
#region Schema Wrapping Tests
/// <summary>
/// Verifies that when requesting an object type, the schema is NOT wrapped.
/// </summary>
[Fact]
public async Task RunAsyncGeneric_WithObjectType_DoesNotWrapSchemaAsync()
{
// Arrange
Animal expectedAnimal = new() { Id = 1, FullName = "Test", Species = Species.Tiger };
string responseJson = JsonSerializer.Serialize(expectedAnimal, TestJsonSerializerContext.Default.Animal);
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, responseJson));
this._agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
// Act
AgentResponse<Animal> result = await this._agentMock.Object.RunAsync<Animal>(
"Get me an animal",
serializerOptions: TestJsonSerializerContext.Default.Options);
// Assert - Verify the result is NOT marked as wrapped
Assert.False(result.IsWrappedInObject);
}
/// <summary>
/// Verifies that when requesting a primitive type (int), the schema IS wrapped.
/// </summary>
[Fact]
public async Task RunAsyncGeneric_WithPrimitiveType_WrapsSchemaAsync()
{
// Arrange
const string ResponseJson = "{\"data\":42}";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
this._agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
// Act
AgentResponse<int> result = await this._agentMock.Object.RunAsync<int>(
"Give me a number",
serializerOptions: TestJsonSerializerContext.Default.Options);
// Assert - Verify the result is marked as wrapped
Assert.True(result.IsWrappedInObject);
}
/// <summary>
/// Verifies that when requesting an array type, the schema IS wrapped.
/// </summary>
[Fact]
public async Task RunAsyncGeneric_WithArrayType_WrapsSchemaAsync()
{
// Arrange
const string ResponseJson = "{\"data\":[\"a\",\"b\",\"c\"]}";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
this._agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
// Act
AgentResponse<string[]> result = await this._agentMock.Object.RunAsync<string[]>(
"Give me an array of strings",
serializerOptions: TestJsonSerializerContext.Default.Options);
// Assert - Verify the result is marked as wrapped
Assert.True(result.IsWrappedInObject);
}
/// <summary>
/// Verifies that when requesting an enum type, the schema IS wrapped.
/// </summary>
[Fact]
public async Task RunAsyncGeneric_WithEnumType_WrapsSchemaAsync()
{
// Arrange
const string ResponseJson = "{\"data\":\"Tiger\"}";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
this._agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
// Act
AgentResponse<Species> result = await this._agentMock.Object.RunAsync<Species>(
"Give me a species",
serializerOptions: TestJsonSerializerContext.Default.Options);
// Assert - Verify the result is marked as wrapped
Assert.True(result.IsWrappedInObject);
}
#endregion
#region AgentResponse<T>.Result Unwrapping Tests
/// <summary>
/// Verifies that AgentResponse{T}.Result correctly deserializes an object without unwrapping.
/// </summary>
[Fact]
public void AgentResponseGeneric_Result_DeserializesObjectWithoutUnwrapping()
{
// Arrange
Animal expectedAnimal = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger };
string responseJson = JsonSerializer.Serialize(expectedAnimal, TestJsonSerializerContext.Default.Animal);
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, responseJson));
AgentResponse<Animal> typedResponse = new(response, TestJsonSerializerContext.Default.Options);
// Act
Animal result = typedResponse.Result;
// Assert
Assert.Equal(expectedAnimal.Id, result.Id);
Assert.Equal(expectedAnimal.FullName, result.FullName);
Assert.Equal(expectedAnimal.Species, result.Species);
}
/// <summary>
/// Verifies that AgentResponse{T}.Result correctly unwraps and deserializes a primitive value.
/// </summary>
[Fact]
public void AgentResponseGeneric_Result_UnwrapsPrimitiveFromDataProperty()
{
// Arrange
const string ResponseJson = "{\"data\":42}";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
AgentResponse<int> typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true };
// Act
int result = typedResponse.Result;
// Assert
Assert.Equal(42, result);
}
/// <summary>
/// Verifies that AgentResponse{T}.Result correctly unwraps and deserializes an array.
/// </summary>
[Fact]
public void AgentResponseGeneric_Result_UnwrapsArrayFromDataProperty()
{
// Arrange
const string ResponseJson = "{\"data\":[\"apple\",\"banana\",\"cherry\"]}";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
AgentResponse<string[]> typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true };
// Act
string[] result = typedResponse.Result;
// Assert
Assert.Equal(["apple", "banana", "cherry"], result);
}
/// <summary>
/// Verifies that AgentResponse{T}.Result correctly unwraps and deserializes an enum.
/// </summary>
[Fact]
public void AgentResponseGeneric_Result_UnwrapsEnumFromDataProperty()
{
// Arrange
const string ResponseJson = "{\"data\":\"Walrus\"}";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
AgentResponse<Species> typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true };
// Act
Species result = typedResponse.Result;
// Assert
Assert.Equal(Species.Walrus, result);
}
/// <summary>
/// Verifies that AgentResponse{T}.Result falls back to original JSON when data property is missing.
/// </summary>
[Fact]
public void AgentResponseGeneric_Result_FallsBackWhenDataPropertyMissing()
{
// Arrange - simulate a case where wrapping was expected but response does not have data
const string ResponseJson = "42";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
AgentResponse<int> typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true };
// Act
int result = typedResponse.Result;
// Assert - should still work by falling back to original JSON
Assert.Equal(42, result);
}
/// <summary>
/// Verifies that AgentResponse{T}.Result throws when response text is empty.
/// </summary>
[Fact]
public void AgentResponseGeneric_Result_ThrowsWhenTextIsEmpty()
{
// Arrange
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, string.Empty));
AgentResponse<int> typedResponse = new(response, TestJsonSerializerContext.Default.Options);
// Act and Assert
Assert.Throws<System.InvalidOperationException>(() => typedResponse.Result);
}
/// <summary>
/// Verifies that AgentResponse{T}.Result throws when deserialized value is null.
/// </summary>
[Fact]
public void AgentResponseGeneric_Result_ThrowsWhenDeserializedValueIsNull()
{
// Arrange
const string ResponseJson = "null";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
AgentResponse<Animal> typedResponse = new(response, TestJsonSerializerContext.Default.Options);
// Act and Assert
Assert.Throws<System.InvalidOperationException>(() => typedResponse.Result);
}
#endregion
#region End-to-End Tests
/// <summary>
/// End-to-end test: Request a primitive type, verify wrapping, and verify correct deserialization.
/// </summary>
[Fact]
public async Task RunAsyncGeneric_PrimitiveEndToEnd_WrapsAndDeserializesCorrectlyAsync()
{
// Arrange
const string ResponseJson = "{\"data\":123}";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
this._agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
// Act
AgentResponse<int> result = await this._agentMock.Object.RunAsync<int>(
"Give me a number",
serializerOptions: TestJsonSerializerContext.Default.Options);
// Assert
Assert.True(result.IsWrappedInObject);
Assert.Equal(123, result.Result);
}
/// <summary>
/// End-to-end test: Request an array type, verify wrapping, and verify correct deserialization.
/// </summary>
[Fact]
public async Task RunAsyncGeneric_ArrayEndToEnd_WrapsAndDeserializesCorrectlyAsync()
{
// Arrange
const string ResponseJson = "{\"data\":[\"one\",\"two\",\"three\"]}";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
this._agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
// Act
AgentResponse<string[]> result = await this._agentMock.Object.RunAsync<string[]>(
"Give me an array of strings",
serializerOptions: TestJsonSerializerContext.Default.Options);
// Assert
Assert.True(result.IsWrappedInObject);
Assert.Equal(["one", "two", "three"], result.Result);
}
/// <summary>
/// End-to-end test: Request an object type, verify no wrapping, and verify correct deserialization.
/// </summary>
[Fact]
public async Task RunAsyncGeneric_ObjectEndToEnd_NoWrappingAndDeserializesCorrectlyAsync()
{
// Arrange
Animal expectedAnimal = new() { Id = 99, FullName = "Leo", Species = Species.Bear };
string responseJson = JsonSerializer.Serialize(expectedAnimal, TestJsonSerializerContext.Default.Animal);
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, responseJson));
this._agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
// Act
AgentResponse<Animal> result = await this._agentMock.Object.RunAsync<Animal>(
"Give me an animal",
serializerOptions: TestJsonSerializerContext.Default.Options);
// Assert
Assert.False(result.IsWrappedInObject);
Assert.Equal(expectedAnimal.Id, result.Result.Id);
Assert.Equal(expectedAnimal.FullName, result.Result.FullName);
Assert.Equal(expectedAnimal.Species, result.Result.Species);
}
/// <summary>
/// End-to-end test: Request an enum type, verify wrapping, and verify correct deserialization.
/// </summary>
[Fact]
public async Task RunAsyncGeneric_EnumEndToEnd_WrapsAndDeserializesCorrectlyAsync()
{
// Arrange
const string ResponseJson = "{\"data\":\"Bear\"}";
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson));
this._agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
// Act
AgentResponse<Species> result = await this._agentMock.Object.RunAsync<Species>(
"Give me a species",
serializerOptions: TestJsonSerializerContext.Default.Options);
// Assert
Assert.True(result.IsWrappedInObject);
Assert.Equal(Species.Bear, result.Result);
}
#endregion
}
@@ -214,30 +214,6 @@ public class AgentResponseTests
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()
{
// Arrange.
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var response = new AgentResponse(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()
{
@@ -246,7 +222,7 @@ public class AgentResponseTests
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
// Act.
var animal = response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options);
var animal = JsonSerializer.Deserialize<Animal>(response.Text, TestJsonSerializerContext.Default.Options);
// Assert.
Assert.NotNull(animal);
@@ -255,98 +231,6 @@ public class AgentResponseTests
Assert.Equal(expectedResult.Species, animal.Species);
}
[Fact]
public void ParseAsStructuredOutputFailsWithEmptyString()
{
// Arrange.
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, string.Empty));
// Act & Assert.
var exception = Assert.Throws<InvalidOperationException>(() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
Assert.Equal("The response did not contain JSON to be deserialized.", exception.Message);
}
[Fact]
public void ParseAsStructuredOutputFailsWithInvalidJson()
{
// Arrange.
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "invalid json"));
// Act & Assert.
Assert.Throws<JsonException>(() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
}
[Fact]
public void ParseAsStructuredOutputFailsWithIncorrectTypedJson()
{
// Arrange.
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "[]"));
// Act & Assert.
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()
{
// Arrange.
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var response = new AgentResponse(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 AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
// Act.
response.TryDeserialize(TestJsonSerializerContext.Default.Options, 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);
}
[Fact]
public void TryParseAsStructuredOutputFailsWithEmptyText()
{
// Arrange.
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, string.Empty));
// Act & Assert.
Assert.False(response.TryDeserialize<Animal>(TestJsonSerializerContext.Default.Options, out _));
}
[Fact]
public void TryParseAsStructuredOutputFailsWithIncorrectTypedJson()
{
// Arrange.
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "[]"));
// Act & Assert.
Assert.False(response.TryDeserialize<Animal>(TestJsonSerializerContext.Default.Options, out _));
}
[Fact]
public void ToAgentResponseUpdatesWithNoMessagesProducesEmptyArray()
{
@@ -395,16 +279,4 @@ public class AgentResponseTests
Assert.NotNull(update.AdditionalProperties);
Assert.Equal("value", update.AdditionalProperties!["key"]);
}
[Fact]
public void Deserialize_ThrowsWhenDeserializationReturnsNull()
{
// Arrange
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, "null"));
// Act & Assert
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
Assert.Equal("The deserialized response is null.", exception.Message);
}
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using Microsoft.Extensions.AI;
@@ -27,7 +26,7 @@ public class AgentRunOptionsTests
};
// Act
var clone = new AgentRunOptions(options);
var clone = options.Clone();
// Assert
Assert.NotNull(clone);
@@ -39,11 +38,6 @@ public class AgentRunOptionsTests
Assert.Equal(42, clone.AdditionalProperties["key2"]);
}
[Fact]
public void CloningConstructorThrowsIfNull() =>
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new AgentRunOptions(null!));
[Fact]
public void JsonSerializationRoundtrips()
{
@@ -77,4 +71,57 @@ public class AgentRunOptionsTests
Assert.IsType<JsonElement>(value2);
Assert.Equal(42, ((JsonElement)value2!).GetInt32());
}
[Fact]
public void CloneReturnsNewInstanceWithSameValues()
{
// Arrange
var options = new AgentRunOptions
{
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
AllowBackgroundResponses = true,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["key1"] = "value1",
["key2"] = 42
},
ResponseFormat = ChatResponseFormat.Json
};
// Act
AgentRunOptions clone = options.Clone();
// Assert
Assert.NotNull(clone);
Assert.IsType<AgentRunOptions>(clone);
Assert.NotSame(options, clone);
Assert.Same(options.ContinuationToken, clone.ContinuationToken);
Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses);
Assert.NotNull(clone.AdditionalProperties);
Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties);
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
Assert.Equal(42, clone.AdditionalProperties["key2"]);
Assert.Same(options.ResponseFormat, clone.ResponseFormat);
}
[Fact]
public void CloneCreatesIndependentAdditionalPropertiesDictionary()
{
// Arrange
var options = new AgentRunOptions
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["key1"] = "value1"
}
};
// Act
AgentRunOptions clone = options.Clone();
clone.AdditionalProperties!["key2"] = "value2";
// Assert
Assert.True(clone.AdditionalProperties.ContainsKey("key2"));
Assert.False(options.AdditionalProperties.ContainsKey("key2"));
}
}
@@ -15,6 +15,7 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests;
[JsonSerializable(typeof(AgentResponseUpdate))]
[JsonSerializable(typeof(AgentRunOptions))]
[JsonSerializable(typeof(Animal))]
[JsonSerializable(typeof(Species))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(string[]))]
@@ -0,0 +1,94 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
/// <summary>
/// Unit tests for the <see cref="DurableAgentRunOptions"/> class.
/// </summary>
public sealed class DurableAgentRunOptionsTests
{
[Fact]
public void CloneReturnsNewInstanceWithSameValues()
{
// Arrange
DurableAgentRunOptions options = new()
{
EnableToolCalls = false,
EnableToolNames = new List<string> { "tool1", "tool2" },
IsFireAndForget = true,
AllowBackgroundResponses = true,
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
AdditionalProperties = new AdditionalPropertiesDictionary
{
["key1"] = "value1",
["key2"] = 42
},
ResponseFormat = ChatResponseFormat.Json
};
// Act
AgentRunOptions cloneAsBase = options.Clone();
// Assert
Assert.NotNull(cloneAsBase);
Assert.IsType<DurableAgentRunOptions>(cloneAsBase);
DurableAgentRunOptions clone = (DurableAgentRunOptions)cloneAsBase;
Assert.NotSame(options, clone);
Assert.Equal(options.EnableToolCalls, clone.EnableToolCalls);
Assert.NotNull(clone.EnableToolNames);
Assert.NotSame(options.EnableToolNames, clone.EnableToolNames);
Assert.Equal(2, clone.EnableToolNames.Count);
Assert.Contains("tool1", clone.EnableToolNames);
Assert.Contains("tool2", clone.EnableToolNames);
Assert.Equal(options.IsFireAndForget, clone.IsFireAndForget);
Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses);
Assert.Same(options.ContinuationToken, clone.ContinuationToken);
Assert.NotNull(clone.AdditionalProperties);
Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties);
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
Assert.Equal(42, clone.AdditionalProperties["key2"]);
Assert.Same(options.ResponseFormat, clone.ResponseFormat);
}
[Fact]
public void CloneCreatesIndependentEnableToolNamesList()
{
// Arrange
DurableAgentRunOptions options = new()
{
EnableToolNames = new List<string> { "tool1" }
};
// Act
DurableAgentRunOptions clone = (DurableAgentRunOptions)options.Clone();
clone.EnableToolNames!.Add("tool2");
// Assert
Assert.Equal(2, clone.EnableToolNames.Count);
Assert.Single(options.EnableToolNames);
Assert.DoesNotContain("tool2", options.EnableToolNames);
}
[Fact]
public void CloneCreatesIndependentAdditionalPropertiesDictionary()
{
// Arrange
DurableAgentRunOptions options = new()
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["key1"] = "value1"
}
};
// Act
DurableAgentRunOptions clone = (DurableAgentRunOptions)options.Clone();
clone.AdditionalProperties!["key2"] = "value2";
// Assert
Assert.True(clone.AdditionalProperties.ContainsKey("key2"));
Assert.False(options.AdditionalProperties.ContainsKey("key2"));
}
}
@@ -332,4 +332,91 @@ public class ChatClientAgentRunOptionsTests
}
#endregion
#region Clone Tests
/// <summary>
/// Verify that Clone returns a new instance with the same property values.
/// </summary>
[Fact]
public void CloneReturnsNewInstanceWithSameValues()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f };
Func<IChatClient, IChatClient> factory = c => c;
var runOptions = new ChatClientAgentRunOptions(chatOptions)
{
ChatClientFactory = factory,
AllowBackgroundResponses = true,
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
AdditionalProperties = new AdditionalPropertiesDictionary
{
["key1"] = "value1"
}
};
// Act
AgentRunOptions cloneAsBase = runOptions.Clone();
// Assert
Assert.NotNull(cloneAsBase);
Assert.IsType<ChatClientAgentRunOptions>(cloneAsBase);
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)cloneAsBase;
Assert.NotSame(runOptions, clone);
Assert.NotNull(clone.ChatOptions);
Assert.NotSame(runOptions.ChatOptions, clone.ChatOptions);
Assert.Equal(100, clone.ChatOptions!.MaxOutputTokens);
Assert.Equal(0.7f, clone.ChatOptions.Temperature);
Assert.Same(factory, clone.ChatClientFactory);
Assert.Equal(runOptions.AllowBackgroundResponses, clone.AllowBackgroundResponses);
Assert.Same(runOptions.ContinuationToken, clone.ContinuationToken);
Assert.NotNull(clone.AdditionalProperties);
Assert.NotSame(runOptions.AdditionalProperties, clone.AdditionalProperties);
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
}
/// <summary>
/// Verify that modifying the cloned ChatOptions does not affect the original.
/// </summary>
[Fact]
public void CloneCreatesIndependentChatOptions()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
var runOptions = new ChatClientAgentRunOptions(chatOptions);
// Act
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)runOptions.Clone();
clone.ChatOptions!.MaxOutputTokens = 200;
// Assert
Assert.Equal(100, runOptions.ChatOptions!.MaxOutputTokens);
Assert.Equal(200, clone.ChatOptions.MaxOutputTokens);
}
/// <summary>
/// Verify that modifying the cloned AdditionalProperties does not affect the original.
/// </summary>
[Fact]
public void CloneCreatesIndependentAdditionalPropertiesDictionary()
{
// Arrange
var runOptions = new ChatClientAgentRunOptions
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["key1"] = "value1"
}
};
// Act
ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)runOptions.Clone();
clone.AdditionalProperties!["key2"] = "value2";
// Assert
Assert.True(clone.AdditionalProperties.ContainsKey("key2"));
Assert.False(runOptions.AdditionalProperties.ContainsKey("key2"));
}
#endregion
}
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
@@ -943,45 +942,6 @@ public partial class ChatClientAgentTests
#endregion
#region RunAsync Structured Output Tests
/// <summary>
/// Verify the invocation of <see cref="ChatClientAgent"/> with specified type parameter is
/// propagated to the underlying <see cref="IChatClient"/> call and the expected structured output is returned.
/// </summary>
[Fact]
public async Task RunAsyncWithTypeParameterInvokesChatClientMethodForStructuredOutputAsync()
{
// Arrange
Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger };
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedSO, JsonContext2.Default.Animal)))
{
ResponseId = "test",
});
ChatClientAgent agent = new(mockService.Object, options: new());
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(messages: [new(ChatRole.User, "Hello")], serializerOptions: JsonContext2.Default.Options);
// Assert
Assert.Single(agentResponse.Messages);
Assert.NotNull(agentResponse.Result);
Assert.Equal(expectedSO.Id, agentResponse.Result.Id);
Assert.Equal(expectedSO.FullName, agentResponse.Result.FullName);
Assert.Equal(expectedSO.Species, agentResponse.Result.Species);
}
#endregion
#region Property Override Tests
/// <summary>
@@ -1999,20 +1959,6 @@ public partial class ChatClientAgentTests
}
}
private sealed class Animal
{
public int Id { get; set; }
public string? FullName { get; set; }
public Species Species { get; set; }
}
private enum Species
{
Bear,
Tiger,
Walrus,
}
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Animal))]
private sealed partial class JsonContext2 : JsonSerializerContext;
@@ -0,0 +1,212 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
public partial class ChatClientAgent_StructuredOutput_WithFormatResponseTests
{
[Fact]
public async Task RunAsync_ResponseFormatProvidedAtAgentInitialization_IsPropagatedToChatClientAsync()
{
// Arrange
ChatResponseFormat? capturedResponseFormat = null;
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
{
ResponseId = "test",
});
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
{
ChatOptions = new ChatOptions()
{
ResponseFormat = responseFormat
}
});
// Act
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]);
// Assert
Assert.NotNull(capturedResponseFormat);
Assert.Same(responseFormat, capturedResponseFormat);
}
[Fact]
public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_IsPropagatedToChatClientAsync()
{
// Arrange
ChatResponseFormat? capturedResponseFormat = null;
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
{
ResponseId = "test",
});
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatClientAgent agent = new(mockService.Object);
ChatClientAgentRunOptions runOptions = new()
{
ResponseFormat = responseFormat
};
// Act
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
// Assert
Assert.NotNull(capturedResponseFormat);
Assert.Same(responseFormat, capturedResponseFormat);
}
[Fact]
public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_OverridesOneProvidedAtAgentInitializationAsync()
{
// Arrange
ChatResponseFormat? capturedResponseFormat = null;
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
{
ResponseId = "test",
});
ChatResponseFormatJson initializationResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatResponseFormatJson invocationResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
{
ChatOptions = new ChatOptions()
{
ResponseFormat = initializationResponseFormat
},
});
ChatClientAgentRunOptions runOptions = new()
{
ResponseFormat = invocationResponseFormat
};
// Act
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
// Assert
Assert.NotNull(capturedResponseFormat);
Assert.Same(invocationResponseFormat, capturedResponseFormat);
Assert.NotSame(initializationResponseFormat, capturedResponseFormat);
}
[Fact]
public async Task RunAsync_ResponseFormatProvidedAtAgentRunOptions_OverridesOneProvidedViaChatOptionsAsync()
{
// Arrange
ChatResponseFormat? capturedResponseFormat = null;
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test"))
{
ResponseId = "test",
});
ChatResponseFormatJson chatOptionsResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatResponseFormatJson runOptionsResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatClientAgent agent = new(mockService.Object);
ChatClientAgentRunOptions runOptions = new()
{
ChatOptions = new ChatOptions
{
ResponseFormat = chatOptionsResponseFormat
},
ResponseFormat = runOptionsResponseFormat
};
// Act
await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions);
// Assert
Assert.NotNull(capturedResponseFormat);
Assert.Same(runOptionsResponseFormat, capturedResponseFormat);
Assert.NotSame(chatOptionsResponseFormat, capturedResponseFormat);
}
[Fact]
public async Task RunAsync_StructuredOutputResponse_IsAvailableAsTextOnAgentResponseAsync()
{
// Arrange
Animal expectedAnimal = new() { FullName = "Wally the Walrus", Id = 1, Species = Species.Walrus };
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedAnimal, JsonContext4.Default.Animal)))
{
ResponseId = "test",
});
ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext4.Default.Options);
ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions
{
ChatOptions = new ChatOptions()
{
ResponseFormat = responseFormat
},
});
// Act
AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]);
// Assert
Assert.NotNull(agentResponse?.Text);
Animal? deserialised = JsonSerializer.Deserialize(agentResponse.Text, JsonContext4.Default.Animal);
Assert.NotNull(deserialised);
Assert.Equal(expectedAnimal.Id, deserialised.Id);
Assert.Equal(expectedAnimal.FullName, deserialised.FullName);
Assert.Equal(expectedAnimal.Species, deserialised.Species);
}
[JsonSerializable(typeof(Animal))]
private sealed partial class JsonContext4 : JsonSerializerContext;
}
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
public partial class ChatClientAgent_StructuredOutput_WithRunAsyncTests
{
[Fact]
public async Task RunAsync_WithGenericType_SetsJsonSchemaResponseFormatAndDeserializesResultAsync()
{
// Arrange
ChatResponseFormat? capturedResponseFormat = null;
ChatResponseFormatJson expectedResponseFormat = ChatResponseFormat.ForJsonSchema<Animal>(JsonContext3.Default.Options);
Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger };
Mock<IChatClient> mockService = new();
mockService.Setup(s => s
.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedSO, JsonContext3.Default.Animal)))
{
ResponseId = "test",
});
ChatClientAgent agent = new(mockService.Object);
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(
messages: [new(ChatRole.User, "Hello")],
serializerOptions: JsonContext3.Default.Options);
// Assert
Assert.NotNull(capturedResponseFormat);
Assert.Equal(expectedResponseFormat.Schema?.GetRawText(), ((ChatResponseFormatJson)capturedResponseFormat).Schema?.GetRawText());
Animal animal = agentResponse.Result;
Assert.NotNull(animal);
Assert.Equal(expectedSO.Id, animal.Id);
Assert.Equal(expectedSO.FullName, animal.FullName);
Assert.Equal(expectedSO.Species, animal.Species);
}
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Animal))]
private sealed partial class JsonContext3 : JsonSerializerContext;
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests;
internal sealed class Animal
{
public int Id { get; set; }
public string? FullName { get; set; }
public Species Species { get; set; }
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests;
internal enum Species
{
Bear,
Tiger,
Walrus,
}
@@ -134,7 +134,7 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
}
}
public static async ValueTask MessagesAsync(string? conversationId, Testcase testcase, WorkflowAgentProvider agentProvider)
public static async ValueTask MessagesAsync(string? conversationId, Testcase testcase, ResponseAgentProvider agentProvider)
{
int minExpectedCount = testcase.Validation.MinMessageCount ?? testcase.Validation.MinResponseCount;
int maxExpectedCount = testcase.Validation.MaxMessageCount ?? testcase.Validation.MaxResponseCount ?? minExpectedCount;
@@ -34,11 +34,11 @@ public abstract class WorkflowActionTemplateTest(ITestOutputHelper output) : Wor
{
if (expected)
{
Assert.Contains(", WorkflowAgentProvider agentProvider", workflowCode);
Assert.Contains($", {nameof(ResponseAgentProvider)} agentProvider", workflowCode);
}
else
{
Assert.DoesNotContain(", WorkflowAgentProvider agentProvider", workflowCode);
Assert.DoesNotContain($", {nameof(ResponseAgentProvider)} agentProvider", workflowCode);
}
}
@@ -14,7 +14,7 @@ public class DeclarativeWorkflowContextTests
public void InitializeDefaultValues()
{
// Act
Mock<WorkflowAgentProvider> mockProvider = new(MockBehavior.Strict);
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Strict);
DeclarativeWorkflowOptions context = new(mockProvider.Object);
// Assert
@@ -34,7 +34,7 @@ public class DeclarativeWorkflowContextTests
ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { });
// Act
Mock<WorkflowAgentProvider> mockProvider = new(MockBehavior.Strict);
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Strict);
DeclarativeWorkflowOptions context = new(mockProvider.Object)
{
MaximumCallDepth = MaxCallDepth,
@@ -65,7 +65,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable
public void ConfigureTelemetry_DefaultIsNull()
{
// Arrange
Mock<WorkflowAgentProvider> mockProvider = CreateMockProvider();
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
// Act
DeclarativeWorkflowOptions options = new(mockProvider.Object);
@@ -78,7 +78,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable
public void ConfigureTelemetry_CanBeSet()
{
// Arrange
Mock<WorkflowAgentProvider> mockProvider = CreateMockProvider();
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
bool callbackInvoked = false;
// Act
@@ -103,7 +103,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable
public void TelemetryActivitySource_DefaultIsNull()
{
// Arrange
Mock<WorkflowAgentProvider> mockProvider = CreateMockProvider();
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
// Act
DeclarativeWorkflowOptions options = new(mockProvider.Object);
@@ -116,7 +116,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable
public void TelemetryActivitySource_CanBeSet()
{
// Arrange
Mock<WorkflowAgentProvider> mockProvider = CreateMockProvider();
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
// Act
DeclarativeWorkflowOptions options = new(mockProvider.Object)
@@ -133,7 +133,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable
{
// Arrange
using Activity testActivity = new Activity("DefaultTelemetryTest").Start()!;
Mock<WorkflowAgentProvider> mockProvider = CreateMockProvider();
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
DeclarativeWorkflowOptions options = new(mockProvider.Object)
{
ConfigureTelemetry = _ => { },
@@ -161,7 +161,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable
{
// Arrange
using Activity testActivity = new Activity("TelemetryActivitySourceTest").Start()!;
Mock<WorkflowAgentProvider> mockProvider = CreateMockProvider();
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
DeclarativeWorkflowOptions options = new(mockProvider.Object)
{
TelemetryActivitySource = this._activitySource,
@@ -188,7 +188,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable
{
// Arrange
using Activity testActivity = new Activity("ConfigureTelemetryTest").Start()!;
Mock<WorkflowAgentProvider> mockProvider = CreateMockProvider();
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
bool configureInvoked = false;
DeclarativeWorkflowOptions options = new(mockProvider.Object)
{
@@ -223,7 +223,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable
{
// Arrange
using Activity testActivity = new Activity("NoTelemetryTest").Start()!;
Mock<WorkflowAgentProvider> mockProvider = CreateMockProvider();
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
DeclarativeWorkflowOptions options = new(mockProvider.Object)
{
LoggerFactory = NullLoggerFactory.Instance
@@ -245,9 +245,9 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable
Assert.Empty(capturedActivities);
}
private static Mock<WorkflowAgentProvider> CreateMockProvider()
private static Mock<ResponseAgentProvider> CreateMockProvider()
{
Mock<WorkflowAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
Mock<ResponseAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
mockAgentProvider
.Setup(provider => provider.CreateConversationAsync(It.IsAny<CancellationToken>()))
.Returns(() => Task.FromResult(Guid.NewGuid().ToString("N")));
@@ -239,7 +239,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
AdaptiveDialog dialog = dialogBuilder.Build();
WorkflowFormulaState state = new(RecalcEngineFactory.Create());
Mock<WorkflowAgentProvider> mockAgentProvider = CreateMockProvider("1");
Mock<ResponseAgentProvider> mockAgentProvider = CreateMockProvider("1");
DeclarativeWorkflowOptions options = new(mockAgentProvider.Object);
WorkflowActionVisitor visitor = new(new DeclarativeWorkflowExecutor<string>(WorkflowActionVisitor.Steps.Root("anything"), options, state, (message) => DeclarativeWorkflowBuilder.DefaultTransform(message)), state, options);
WorkflowElementWalker walker = new(visitor);
@@ -374,14 +374,14 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
private Workflow CreateWorkflow<TInput>(string workflowPath, TInput workflowInput) where TInput : notnull
{
using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath));
Mock<WorkflowAgentProvider> mockAgentProvider = CreateMockProvider($"{workflowInput}");
Mock<ResponseAgentProvider> mockAgentProvider = CreateMockProvider($"{workflowInput}");
DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output };
return DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
}
private static Mock<WorkflowAgentProvider> CreateMockProvider(string input)
private static Mock<ResponseAgentProvider> CreateMockProvider(string input)
{
Mock<WorkflowAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
Mock<ResponseAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
mockAgentProvider.Setup(provider => provider.CreateConversationAsync(It.IsAny<CancellationToken>())).Returns(() => Task.FromResult(Guid.NewGuid().ToString("N")));
mockAgentProvider.Setup(provider => provider.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, input)));
return mockAgentProvider;
@@ -58,7 +58,7 @@ public sealed class DeclarativeWorkflowOptionsExtensionsTests
int? maximumExpressionLength = null,
int? maximumCallDepth = null)
{
Mock<WorkflowAgentProvider> providerMock = new(MockBehavior.Strict);
Mock<ResponseAgentProvider> providerMock = new(MockBehavior.Strict);
return
new(providerMock.Object)
{
@@ -11,9 +11,9 @@ using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Mock implementation of <see cref="WorkflowAgentProvider"/> for unit testing purposes.
/// Mock implementation of <see cref="ResponseAgentProvider"/> for unit testing purposes.
/// </summary>
internal sealed class MockAgentProvider : Mock<WorkflowAgentProvider>
internal sealed class MockAgentProvider : Mock<ResponseAgentProvider>
{
public IList<string> ExistingConversationIds { get; } = [];
@@ -1,4 +1,4 @@
// ------------------------------------------------------------------------------
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
@@ -52,7 +52,7 @@ public static class WorkflowProvider
/// <summary>
/// Adds a new message to the specified agent conversation
/// </summary>
internal sealed class AddMessageExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "add_message", session)
internal sealed class AddMessageExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "add_message", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -116,4 +116,4 @@ public static class WorkflowProvider
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
}
@@ -1,4 +1,4 @@
// ------------------------------------------------------------------------------
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
@@ -49,7 +49,7 @@ public static class WorkflowProvider
/// <summary>
/// Copies one or more messages into the specified agent conversation.
/// </summary>
internal sealed class CopyMessagesExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "copy_messages", session)
internal sealed class CopyMessagesExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "copy_messages", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -92,4 +92,4 @@ public static class WorkflowProvider
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
}
@@ -1,4 +1,4 @@
// ------------------------------------------------------------------------------
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
@@ -51,7 +51,7 @@ public static class WorkflowProvider
/// <summary>
/// Creates a new conversation and stores the identifier value to the "Local.PrivateConversationId" variable.
/// </summary>
internal sealed class ConversationCreateExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "conversation_create", session)
internal sealed class ConversationCreateExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "conversation_create", session)
{
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
@@ -85,4 +85,4 @@ public static class WorkflowProvider
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
}
@@ -1,4 +1,4 @@
// ------------------------------------------------------------------------------
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
@@ -54,7 +54,7 @@ public static class WorkflowProvider
/// <summary>
/// Invokes an agent to process messages and return a response within a conversation context.
/// </summary>
internal sealed class InvokeAgentExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "invoke_agent", session, agentProvider)
internal sealed class InvokeAgentExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExecutor(id: "invoke_agent", session, agentProvider)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -109,4 +109,4 @@ public static class WorkflowProvider
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
}
@@ -1,4 +1,4 @@
// ------------------------------------------------------------------------------
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
@@ -53,7 +53,7 @@ public static class WorkflowProvider
/// <summary>
/// Retrieves a list of messages from an agent conversation.
/// </summary>
internal sealed class GetMessageSingleExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "get_message_single", session)
internal sealed class GetMessageSingleExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "get_message_single", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -88,4 +88,4 @@ public static class WorkflowProvider
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
}
@@ -1,4 +1,4 @@
// ------------------------------------------------------------------------------
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
@@ -51,7 +51,7 @@ public static class WorkflowProvider
/// <summary>
/// Retrieves a specific message from an agent conversation.
/// </summary>
internal sealed class GetMessagesAllExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "get_messages_all", session)
internal sealed class GetMessagesAllExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "get_messages_all", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
@@ -101,4 +101,4 @@ public static class WorkflowProvider
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace OpenAIAssistant.IntegrationTests;
public class OpenAIAssistantStructuredOutputRunTests() : StructuredOutputRunTests<OpenAIAssistantFixture>(() => new())
{
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace OpenAIChatCompletion.IntegrationTests;
public class OpenAIChatCompletionStructuredOutputRunTests() : StructuredOutputRunTests<OpenAIChatCompletionFixture>(() => new(useReasoningChatModel: false))
{
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseStructuredOutputRunTests() : StructuredOutputRunTests<OpenAIResponseFixture>(() => new(store: false))
{
}
+25 -1
View File
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b260212] - 2026-02-12
### Added
- **agent-framework-core**: Allow `AzureOpenAIResponsesClient` creation with Foundry project endpoint ([#3814](https://github.com/microsoft/agent-framework/pull/3814))
### Changed
- **agent-framework-core**: [BREAKING] Wire context provider pipeline, remove old types, update all consumers ([#3850](https://github.com/microsoft/agent-framework/pull/3850))
- **agent-framework-core**: [BREAKING] Checkpoint refactor: encode/decode, checkpoint format, etc ([#3744](https://github.com/microsoft/agent-framework/pull/3744))
- **agent-framework-core**: [BREAKING] Replace `Hosted*Tool` classes with tool methods ([#3634](https://github.com/microsoft/agent-framework/pull/3634))
- **agent-framework-core**: Replace Pydantic Settings with `TypedDict` + `load_settings()` ([#3843](https://github.com/microsoft/agent-framework/pull/3843))
- **agent-framework-core**: Centralize tool result parsing in `FunctionTool.invoke()` ([#3854](https://github.com/microsoft/agent-framework/pull/3854))
- **samples**: Restructure Python samples into progressive 01-05 layout ([#3862](https://github.com/microsoft/agent-framework/pull/3862))
- **samples**: Adopt `AzureOpenAIResponsesClient`, reorganize orchestration examples, and fix workflow/orchestration bugs ([#3873](https://github.com/microsoft/agent-framework/pull/3873))
### Fixed
- **agent-framework-core**: Fix non-ascii chars in span attributes ([#3894](https://github.com/microsoft/agent-framework/pull/3894))
- **agent-framework-core**: Fix streamed workflow agent continuation context by finalizing `AgentExecutor` streams ([#3882](https://github.com/microsoft/agent-framework/pull/3882))
- **agent-framework-ag-ui**: Fix `Workflow.as_agent()` streaming regression ([#3875](https://github.com/microsoft/agent-framework/pull/3875))
- **agent-framework-declarative**: Fix declarative package powerfx import crash and `response_format` kwarg error ([#3841](https://github.com/microsoft/agent-framework/pull/3841))
## [1.0.0b260210] - 2026-02-10
### Added
@@ -622,7 +645,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.0b260210...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260212...HEAD
[1.0.0b260212]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260210...python-1.0.0b260212
[1.0.0b260210]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260130...python-1.0.0b260210
[1.0.0b260130]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260128...python-1.0.0b260130
[1.0.0b260128]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260127...python-1.0.0b260128
+21 -6
View File
@@ -403,22 +403,37 @@ If in doubt, use the link above to read much more considerations of what to do a
### Explicit Exports
> **Note:** This convention is being adopted. See [#3605](https://github.com/microsoft/agent-framework/issues/3605) for progress.
**All wildcard imports (`from ... import *`) are prohibited** in production code, including both `.py` and `.pyi` files. Always use explicit import lists to maintain clarity and avoid namespace pollution.
Define `__all__` in each module to explicitly declare the public API. Avoid using `from module import *` in `__init__.py` files as it can impact performance and makes the public API unclear:
Define `__all__` in each module to explicitly declare the public API, then import specific symbols by name:
```python
# âś… Preferred - explicit __all__ and imports
# âś… Preferred - explicit __all__ and named imports
__all__ = ["Agent", "Message", "ChatResponse"]
from ._agents import Agent
from ._types import Message, ChatResponse
# ❌ Avoid - star imports
from ._agents import *
from ._types import *
# âś… For many exports, use parenthesized multi-line imports
from ._types import (
AgentResponse,
ChatResponse,
Message,
ResponseStream,
)
# ❌ Prohibited pattern: wildcard/star imports (do not use)
# from ._agents import <all public symbols>
# from ._types import <all public symbols>
```
**Rationale:**
- **Clarity**: Explicit imports make it clear exactly what is being exported and used
- **IDE Support**: Enables better autocomplete, go-to-definition, and refactoring
- **Type Checking**: Improves static analysis and type checker accuracy
- **Maintenance**: Makes it easier to track symbol usage and detect breaking changes
- **Performance**: Avoids unnecessary symbol resolution during module import
## Performance considerations
### Cache Expensive Computations
+1 -1
View File
@@ -233,7 +233,7 @@ if __name__ == "__main__":
asyncio.run(main())
```
For more advanced orchestration patterns including Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations, see the [orchestration samples](samples/02-agents/orchestrations).
For more advanced orchestration patterns including Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations, see the [orchestration samples](samples/03-workflows/orchestrations).
## More Examples & Samples
@@ -31,7 +31,7 @@ from a2a.types import Role as A2ARole
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
Content,
ContinuationToken,
@@ -211,7 +211,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
continuation_token: A2AContinuationToken | None = None,
background: bool = False,
**kwargs: Any,
@@ -223,7 +223,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[True],
thread: AgentThread | None = None,
session: AgentSession | None = None,
continuation_token: A2AContinuationToken | None = None,
background: bool = False,
**kwargs: Any,
@@ -234,7 +234,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
continuation_token: A2AContinuationToken | None = None,
background: bool = False,
**kwargs: Any,
@@ -246,7 +246,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
Keyword Args:
stream: Whether to stream the response. Defaults to False.
thread: The conversation thread associated with the message(s).
session: The conversation session associated with the message(s).
continuation_token: Optional token to resume a long-running task
instead of starting a new one.
background: When True, in-progress task updates surface continuation
+2 -2
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.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"a2a-sdk>=0.3.5",
]
@@ -18,7 +18,7 @@ class AgentConfig:
self,
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
use_service_thread: bool = False,
use_service_session: bool = False,
require_confirmation: bool = True,
):
"""Initialize agent configuration.
@@ -26,12 +26,12 @@ class AgentConfig:
Args:
state_schema: Optional state schema for state management; accepts dict or Pydantic model/class
predict_state_config: Configuration for predictive state updates
use_service_thread: Whether the agent thread is service-managed
use_service_session: Whether the agent session is service-managed
require_confirmation: Whether predictive updates require user confirmation before applying
"""
self.state_schema = self._normalize_state_schema(state_schema)
self.predict_state_config = predict_state_config or {}
self.use_service_thread = use_service_thread
self.use_service_session = use_service_session
self.require_confirmation = require_confirmation
@staticmethod
@@ -77,7 +77,7 @@ class AgentFrameworkAgent:
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
require_confirmation: bool = True,
use_service_thread: bool = False,
use_service_session: bool = False,
):
"""Initialize the AG-UI compatible agent wrapper.
@@ -88,7 +88,7 @@ class AgentFrameworkAgent:
state_schema: Optional state schema for state management; accepts dict or Pydantic model/class
predict_state_config: Configuration for predictive state updates
require_confirmation: Whether predictive updates require user confirmation before applying
use_service_thread: Whether the agent thread is service-managed
use_service_session: Whether the agent session is service-managed
"""
self.agent = agent
self.name = name or getattr(agent, "name", "agent")
@@ -97,7 +97,7 @@ class AgentFrameworkAgent:
self.config = AgentConfig(
state_schema=state_schema,
predict_state_config=predict_state_config,
use_service_thread=use_service_thread,
use_service_session=use_service_session,
require_confirmation=require_confirmation,
)
@@ -171,11 +171,11 @@ class AGUIChatClient(
client = AGUIChatClient(endpoint="http://localhost:8888/")
agent = Agent(name="assistant", client=client)
thread = await agent.get_new_thread()
session = agent.create_session()
# Agent automatically maintains history and sends full context
response = await agent.run("Hello!", thread=thread)
response2 = await agent.run("How are you?", thread=thread)
response = await agent.run("Hello!", session=session)
response2 = await agent.run("How are you?", session=session)
Streaming usage:
@@ -7,7 +7,7 @@ from __future__ import annotations
import json
import logging
import uuid
from collections.abc import Awaitable
from collections.abc import AsyncIterable, Awaitable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, cast
@@ -27,7 +27,7 @@ from ag_ui.core import (
ToolCallStartEvent,
)
from agent_framework import (
AgentThread,
AgentSession,
Content,
Message,
SupportsAgentRun,
@@ -172,12 +172,12 @@ class FlowState:
tool_call_id: str | None = None # Current tool call being streamed
tool_call_name: str | None = None # Name of current tool call
waiting_for_approval: bool = False # Stop after approval request
current_state: dict[str, Any] = field(default_factory=dict) # Shared state
current_state: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
accumulated_text: str = "" # For MessagesSnapshotEvent
pending_tool_calls: list[dict[str, Any]] = field(default_factory=list) # For MessagesSnapshotEvent
tool_calls_by_id: dict[str, dict[str, Any]] = field(default_factory=dict)
tool_results: list[dict[str, Any]] = field(default_factory=list)
tool_calls_ended: set[str] = field(default_factory=set) # Track which tool calls have been ended
pending_tool_calls: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
tool_calls_by_id: dict[str, dict[str, Any]] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType]
def get_tool_name(self, call_id: str | None) -> str | None:
"""Get tool name by call ID."""
@@ -191,6 +191,40 @@ class FlowState:
return [tc for tc in self.pending_tool_calls if tc.get("id") not in self.tool_calls_ended]
async def _normalize_response_stream(response_stream: Any) -> AsyncIterable[Any]:
"""Normalize agent streaming return types to an async iterable.
Supports:
- ResponseStream (standard agent stream type)
- AsyncIterable[AgentResponseUpdate] (workflow-style stream)
- Awaitable that resolves to either of the above
"""
if isinstance(response_stream, Awaitable):
resolved_stream = await cast(Awaitable[Any], response_stream)
if isinstance(resolved_stream, ResponseStream):
# AG-UI consumes update iteration only; ResponseStream finalizers are not used here.
return cast(AsyncIterable[Any], resolved_stream)
if isinstance(resolved_stream, AsyncIterable):
return cast(AsyncIterable[Any], resolved_stream)
resolved_type = f"{type(resolved_stream).__module__}.{type(resolved_stream).__name__}"
raise AgentExecutionException(
"Agent did not return a streaming AsyncIterable response. "
f"Awaitable resolved to unsupported type: {resolved_type}."
)
if isinstance(response_stream, ResponseStream):
# AG-UI consumes update iteration only; ResponseStream finalizers are not used here.
return cast(AsyncIterable[Any], response_stream)
if isinstance(response_stream, AsyncIterable):
return cast(AsyncIterable[Any], response_stream)
stream_type = f"{type(response_stream).__module__}.{type(response_stream).__name__}"
raise AgentExecutionException(
f"Agent did not return a streaming AsyncIterable response. Received unsupported type: {stream_type}."
)
def _create_state_context_message(
current_state: dict[str, Any],
state_schema: dict[str, Any],
@@ -460,7 +494,7 @@ def _emit_approval_request(
parent_message_id=flow.message_id,
)
)
args = {
args: dict[str, Any] = {
"function_name": func_name,
"function_call_id": func_call_id,
"function_arguments": make_json_safe(func_call.parse_arguments()) or {},
@@ -515,7 +549,8 @@ def _is_confirm_changes_response(messages: list[Any]) -> bool:
if not messages:
return False
last = messages[-1]
if not last.additional_properties.get("is_tool_result", False):
additional_properties = cast(dict[str, Any], getattr(last, "additional_properties", {}) or {})
if not additional_properties.get("is_tool_result", False):
return False
# Parse the content to check if it has the confirm_changes structure
@@ -523,6 +558,8 @@ def _is_confirm_changes_response(messages: list[Any]) -> bool:
if getattr(content, "type", None) == "text" and content.text:
try:
result = json.loads(content.text)
if not isinstance(result, dict):
continue
# confirm_changes results have 'accepted' and 'steps' keys
if "accepted" in result and "steps" in result:
return True
@@ -548,13 +585,19 @@ def _handle_step_based_approval(messages: list[Any]) -> list[BaseEvent]:
message = "Acknowledged."
else:
try:
result = json.loads(approval_text)
accepted = result.get("accepted", False)
steps = result.get("steps", [])
parsed_result = json.loads(approval_text)
result: dict[str, Any] = cast(dict[str, Any], parsed_result) if isinstance(parsed_result, dict) else {}
accepted = bool(result.get("accepted", False))
steps_raw = result.get("steps", [])
steps: list[dict[str, Any]] = []
if isinstance(steps_raw, list):
for step_raw in cast(list[Any], steps_raw):
if isinstance(step_raw, dict):
steps.append(cast(dict[str, Any], step_raw))
if accepted:
# Generate acceptance message with step descriptions
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
enabled_steps: list[dict[str, Any]] = [step for step in steps if step.get("status") == "enabled"]
if enabled_steps:
message_parts = [f"Executing {len(enabled_steps)} approved steps:\n\n"]
for i, step in enumerate(enabled_steps, 1):
@@ -678,8 +721,9 @@ def _convert_approval_results_to_tool_messages(messages: list[Any]) -> None:
result.append(msg)
continue
function_results = [c for c in (msg.contents or []) if getattr(c, "type", None) == "function_result"]
other_contents = [c for c in (msg.contents or []) if getattr(c, "type", None) != "function_result"]
msg_contents = cast(list[Content], getattr(msg, "contents", None) or [])
function_results: list[Content] = [content for content in msg_contents if content.type == "function_result"]
other_contents: list[Content] = [content for content in msg_contents if content.type != "function_result"]
if not function_results:
result.append(msg)
@@ -695,7 +739,7 @@ def _convert_approval_results_to_tool_messages(messages: list[Any]) -> None:
# Then user message with remaining content (if any)
if other_contents:
result.append(Message(role=msg.role, contents=other_contents))
result.append(Message(role="user", contents=other_contents))
messages[:] = result
@@ -765,21 +809,24 @@ async def run_agent_stream(
if input_data.get("state"):
flow.current_state = dict(input_data["state"])
state_schema = cast(dict[str, Any], getattr(config, "state_schema", {}) or {})
predict_state_config = cast(dict[str, dict[str, str]], getattr(config, "predict_state_config", {}) or {})
# Apply schema defaults for missing state keys
if config.state_schema:
for key, schema in config.state_schema.items():
if state_schema:
for key, schema in state_schema.items():
if key in flow.current_state:
continue
if isinstance(schema, dict) and schema.get("type") == "array":
if isinstance(schema, dict) and cast(dict[str, Any], schema).get("type") == "array":
flow.current_state[key] = []
else:
flow.current_state[key] = {}
# Initialize predictive state handler if configured
predictive_handler: PredictiveStateHandler | None = None
if config.predict_state_config:
if predict_state_config:
predictive_handler = PredictiveStateHandler(
predict_state_config=config.predict_state_config,
predict_state_config=predict_state_config,
current_state=flow.current_state,
)
@@ -789,11 +836,11 @@ async def run_agent_stream(
# Check for structured output mode (skip text content)
skip_text = False
response_format = None
from agent_framework import Agent
if isinstance(agent, Agent):
response_format = agent.default_options.get("response_format")
response_format: type[Any] | None = None
default_options = getattr(agent, "default_options", None)
if isinstance(default_options, dict):
typed_default_options = cast(dict[str, Any], default_options)
response_format = cast(type[Any] | None, typed_default_options.get("response_format"))
skip_text = response_format is not None
# Handle empty messages (emit RunStarted immediately since no agent response)
@@ -809,12 +856,12 @@ async def run_agent_stream(
register_additional_client_tools(agent, client_tools)
tools = merge_tools(server_tools, client_tools)
# Create thread (with service thread support)
if config.use_service_thread:
# Create session (with service session support)
if config.use_service_session:
supplied_thread_id = input_data.get("thread_id") or input_data.get("threadId")
thread = AgentThread(service_thread_id=supplied_thread_id)
session = AgentSession(service_session_id=supplied_thread_id)
else:
thread = AgentThread()
session = AgentSession()
# Inject metadata for AG-UI orchestration (Feature #2: Azure-safe truncation)
base_metadata: dict[str, Any] = {
@@ -823,16 +870,17 @@ async def run_agent_stream(
}
if flow.current_state:
base_metadata["current_state"] = flow.current_state
thread.metadata = _build_safe_metadata(base_metadata) # type: ignore[attr-defined]
session.metadata = _build_safe_metadata(base_metadata) # type: ignore[attr-defined]
# Build run kwargs (Feature #6: Azure store flag when metadata present)
run_kwargs: dict[str, Any] = {"thread": thread}
run_kwargs: dict[str, Any] = {"session": session}
if tools:
run_kwargs["tools"] = tools
# Filter out AG-UI internal metadata keys before passing to chat client
# These are used internally for orchestration and should not be sent to the LLM provider
client_metadata = {
k: v for k, v in (getattr(thread, "metadata", None) or {}).items() if k not in AG_UI_INTERNAL_METADATA_KEYS
session_metadata = cast(dict[str, Any], getattr(session, "metadata", None) or {})
client_metadata: dict[str, Any] = {
k: v for k, v in session_metadata.items() if k not in AG_UI_INTERNAL_METADATA_KEYS
}
safe_metadata = _build_safe_metadata(client_metadata) if client_metadata else {}
if safe_metadata:
@@ -863,19 +911,14 @@ async def run_agent_stream(
# Inject state context message so the model knows current application state
# This is critical for shared state scenarios where the UI state needs to be visible
if config.state_schema and flow.current_state:
messages = _inject_state_context(messages, flow.current_state, config.state_schema)
if state_schema and flow.current_state:
messages = _inject_state_context(messages, flow.current_state, state_schema)
# Stream from agent - emit RunStarted after first update to get service IDs
run_started_emitted = False
all_updates: list[Any] = [] # Collect for structured output processing
response_stream = agent.run(messages, stream=True, **run_kwargs)
if isinstance(response_stream, ResponseStream):
stream = response_stream
else:
stream = await cast(Awaitable[ResponseStream[Any, Any]], response_stream)
if not isinstance(stream, ResponseStream):
raise AgentExecutionException("Chat client did not return a ResponseStream.")
stream = await _normalize_response_stream(response_stream)
async for update in stream:
# Collect updates for structured output processing
if response_format is not None:
@@ -891,18 +934,18 @@ async def run_agent_stream(
# NOW emit RunStarted with proper IDs
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
# Emit PredictState custom event if configured
if config.predict_state_config:
if predict_state_config:
predict_state_value = [
{
"state_key": state_key,
"tool": cfg["tool"],
"tool_argument": cfg["tool_argument"],
}
for state_key, cfg in config.predict_state_config.items()
for state_key, cfg in predict_state_config.items()
]
yield CustomEvent(name="PredictState", value=predict_state_value)
# Emit initial state snapshot only if we have both state_schema and state
if config.state_schema and flow.current_state:
if state_schema and flow.current_state:
yield StateSnapshotEvent(snapshot=flow.current_state)
run_started_emitted = True
@@ -933,17 +976,17 @@ async def run_agent_stream(
# If no updates at all, still emit RunStarted
if not run_started_emitted:
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
if config.predict_state_config:
if predict_state_config:
predict_state_value = [
{
"state_key": state_key,
"tool": cfg["tool"],
"tool_argument": cfg["tool_argument"],
}
for state_key, cfg in config.predict_state_config.items()
for state_key, cfg in predict_state_config.items()
]
yield CustomEvent(name="PredictState", value=predict_state_value)
if config.state_schema and flow.current_state:
if state_schema and flow.current_state:
yield StateSnapshotEvent(snapshot=flow.current_state)
# Process structured output if response_format is set
@@ -951,31 +994,33 @@ async def run_agent_stream(
from agent_framework import AgentResponse
from pydantic import BaseModel
logger.info(f"Processing structured output, update count: {len(all_updates)}")
final_response = AgentResponse.from_updates(all_updates, output_format_type=response_format)
if not (isinstance(response_format, type) and issubclass(response_format, BaseModel)):
logger.warning("Skipping structured output parsing: response_format is not a Pydantic model type.")
else:
logger.info(f"Processing structured output, update count: {len(all_updates)}")
final_response = AgentResponse.from_updates(all_updates, output_format_type=response_format)
if final_response.value and isinstance(final_response.value, BaseModel):
response_dict = final_response.value.model_dump(mode="json", exclude_none=True)
logger.info(f"Received structured output keys: {list(response_dict.keys())}")
if final_response.value and isinstance(final_response.value, BaseModel):
response_dict = final_response.value.model_dump(mode="json", exclude_none=True)
logger.info(f"Received structured output keys: {list(response_dict.keys())}")
# Extract state updates - if no state_schema, all non-message fields are state
state_keys = (
set(config.state_schema.keys()) if config.state_schema else set(response_dict.keys()) - {"message"}
)
state_updates = {k: v for k, v in response_dict.items() if k in state_keys}
# Extract state updates - if no state_schema, all non-message fields are state
state_keys = set(state_schema.keys()) if state_schema else set(response_dict.keys()) - {"message"}
state_updates = {k: v for k, v in response_dict.items() if k in state_keys}
if state_updates:
flow.current_state.update(state_updates)
yield StateSnapshotEvent(snapshot=flow.current_state)
logger.info(f"Emitted StateSnapshotEvent with updates: {list(state_updates.keys())}")
if state_updates:
flow.current_state.update(state_updates)
yield StateSnapshotEvent(snapshot=flow.current_state)
logger.info(f"Emitted StateSnapshotEvent with updates: {list(state_updates.keys())}")
# Emit message field as text if present
if "message" in response_dict and response_dict["message"]:
message_id = generate_event_id()
yield TextMessageStartEvent(message_id=message_id, role="assistant")
yield TextMessageContentEvent(message_id=message_id, delta=response_dict["message"])
yield TextMessageEndEvent(message_id=message_id)
logger.info(f"Emitted conversational message with length={len(response_dict['message'])}")
# Emit message field as text if present
message_text = response_dict.get("message")
if isinstance(message_text, str) and message_text:
message_id = generate_event_id()
yield TextMessageStartEvent(message_id=message_id, role="assistant")
yield TextMessageContentEvent(message_id=message_id, delta=message_text)
yield TextMessageEndEvent(message_id=message_id)
logger.info(f"Emitted conversational message with length={len(message_text)}")
# Feature #1: Emit ToolCallEndEvent for declaration-only tools (tools without results)
pending_without_end = flow.get_pending_without_end()
@@ -989,8 +1034,8 @@ async def run_agent_stream(
yield ToolCallEndEvent(tool_call_id=tool_call_id)
# For predictive tools with require_confirmation, emit confirm_changes
if config.require_confirmation and config.predict_state_config and tool_name:
is_predictive_tool = any(cfg["tool"] == tool_name for cfg in config.predict_state_config.values())
if config.require_confirmation and predict_state_config and tool_name:
is_predictive_tool = any(cfg["tool"] == tool_name for cfg in predict_state_config.values())
if is_predictive_tool:
logger.info(f"Emitting confirm_changes for predictive tool '{tool_name}'")
# Extract state value from tool arguments for StateSnapshot
@@ -1071,7 +1116,7 @@ async def run_agent_stream(
last_call_id = last_result.get("toolCallId")
last_tool_name = flow.get_tool_name(last_call_id)
if not _should_suppress_intermediate_snapshot(
last_tool_name, config.predict_state_config, config.require_confirmation
last_tool_name, predict_state_config, config.require_confirmation
):
yield _build_messages_snapshot(flow, snapshot_messages)

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