Skip to content

Commit cb9a2c3

Browse files
committed
Merge branch 'main' into samples-fix
2 parents b6ca4d4 + 9bfa593 commit cb9a2c3

95 files changed

Lines changed: 6208 additions & 741 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/decisions/0021-agent-skills-design.md

Lines changed: 960 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
---
2+
status: accepted
3+
contact: westey-m
4+
date: 2026-03-23
5+
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub
6+
consulted:
7+
informed:
8+
---
9+
10+
# Chat History Persistence Consistency
11+
12+
## Context and Problem Statement
13+
14+
When using `ChatClientAgent` with tools, the `FunctionInvokingChatClient` (FIC) loops multiple times — service call → tool execution → service call → … — before producing a final response. There are two points of discrepancy between how chat history is stored by the framework's `ChatHistoryProvider` and how the underlying AI service stores chat history (e.g., OpenAI Responses with `store=true`):
15+
16+
1. **Persistence timing**: The AI service persists messages after *each* service call within the FIC loop. The `ChatHistoryProvider` currently persists messages only once, at the *end* of the full agent run (after all FIC loop iterations complete).
17+
18+
2. **Trailing `FunctionResultContent` storage**: When tool calling is terminated mid-loop (e.g., via `FunctionInvokingChatClient` termination filters), the final response from the agent may contain `FunctionResultContent` that was never sent to a subsequent service call. The AI service never stores this trailing `FunctionResultContent`, but the `ChatHistoryProvider` currently stores all response content, including the trailing `FunctionResultContent`.
19+
20+
These discrepancies mean that a `ChatHistoryProvider`-managed conversation and a service-managed conversation can diverge in content and structure, even when processing the same interactions.
21+
22+
### Practical Impact: Resuming After Tool-Call Termination
23+
24+
Today, users of `AIAgent` get different behaviors depending on whether chat history is stored service-side or in a `ChatHistoryProvider`. This creates concrete challenges — for example, when the function call loop is terminated and the user wants to resume the conversation in a subsequent run. With service-stored history, the trailing `FunctionResultContent` is never persisted, so the last stored message is the `FunctionCallContent` from the service. With `ChatHistoryProvider`-stored history, the trailing `FunctionResultContent` *is* persisted. The user cannot know whether the last `FunctionResultContent` is in the chat history or not without inspecting the storage mechanism, making it difficult to write resumption logic that works correctly regardless of the storage backend.
25+
26+
### Relationship Between the Two Discrepancies
27+
28+
The persistence timing and `FunctionResultContent` trimming behaviors are interrelated:
29+
30+
- **Per-service-call persistence**: When messages are persisted after each individual service call, trailing `FunctionResultContent` trimming is unnecessary. If tool calling is terminated, the `FunctionResultContent` from the terminated call was never sent to a subsequent service call, so it is never persisted. The per-service-call approach naturally matches the service's behavior.
31+
32+
- **Per-run persistence**: When messages are batched and persisted at the end of the full run, trailing `FunctionResultContent` trimming becomes necessary to match the service's behavior. Without trimming, the stored history contains `FunctionResultContent` that the service would never have stored.
33+
34+
This means the trimming feature (introduced in [PR #4792](https://github.com/microsoft/agent-framework/pull/4792)) is primarily needed as a complement to per-run persistence. The `PersistChatHistoryAtEndOfRun` setting (introduced in [PR #4762](https://github.com/microsoft/agent-framework/pull/4762)) inverts the default so that per-service-call persistence is the standard behavior, and per-run persistence is opt-in.
35+
36+
## Decision Drivers
37+
38+
- **A. Consistency**: The default behavior of `ChatHistoryProvider` should produce stored history that closely matches what the underlying AI service would store, minimizing surprise when switching between framework-managed and service-managed chat history.
39+
- **B. Atomicity**: A run that fails mid-way through a multi-step tool-calling loop should not leave chat history in a partially-updated state, unless the user explicitly opts into that behavior.
40+
- **C. Recoverability**: For long-running tool-calling loops, it should be possible to recover intermediate progress if the process is interrupted, rather than losing all work from the current run.
41+
- **D. Simplicity**: The default behavior should be easy to understand and predict for most users, without requiring knowledge of the FIC loop internals.
42+
- **E. Flexibility**: Regardless of the chosen default, users should be able to opt into the alternative behavior.
43+
44+
## Considered Options
45+
46+
- Option 1: Default to per-run persistence with `FunctionResultContent` trimming (opt-in to per-service-call)
47+
- Option 2: Default to per-service-call persistence (opt-in to per-run)
48+
49+
## Pros and Cons of the Options
50+
51+
### Option 1: Default to per-run persistence with `FunctionResultContent` trimming
52+
53+
Keep the current default behavior of persisting chat history only at the end of the full agent run. Add `FunctionResultContent` trimming as the default to improve consistency with service storage. Provide an opt-in setting for users who want per-service-call persistence.
54+
55+
Settings:
56+
- `PersistChatHistoryAtEndOfRun` = `true`
57+
58+
- Good, because runs are atomic — chat history is only updated when the full run succeeds, satisfying driver B.
59+
- Good, because the mental model is simple: one run = one history update, satisfying driver D.
60+
- Good, because trimming trailing `FunctionResultContent` improves consistency with service storage, partially satisfying driver A.
61+
- Good, because users can opt in to per-service-call persistence for checkpointing/recovery scenarios, satisfying drivers C and E.
62+
- Bad, because the default persistence timing still differs from the service's behavior (per-run vs. per-service-call), only partially satisfying driver A.
63+
- Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C by default.
64+
65+
### Option 2: Default to per-service-call persistence
66+
67+
Change the default to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled). Provide an opt-in setting for users who want per-run atomicity with trimming.
68+
69+
Settings:
70+
- `PersistChatHistoryAtEndOfRun` = `false` (default)
71+
72+
- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying driver A.
73+
- Good, because intermediate progress is preserved if the process is interrupted, satisfying driver C.
74+
- Good, because no separate `FunctionResultContent` trimming logic is needed, reducing complexity.
75+
- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), not satisfying driver B. A subsequent run cannot proceed without manually providing the missing `FunctionResultContent`.
76+
- Bad, because the mental model is more complex: a single run may produce multiple history updates, partially failing driver D.
77+
- Neutral, because users can opt out to per-run persistence if they prefer atomicity, satisfying driver E.
78+
79+
## Decision Outcome
80+
81+
Chosen option: **Option 2 — Default to per-service-call persistence**, because it fully satisfies the consistency driver (A), naturally handles `FunctionResultContent` trimming without additional logic, and provides better recoverability for long-running tool-calling loops. Per-run persistence remains available via the `PersistChatHistoryAtEndOfRun` setting for users who prefer atomic run semantics.
82+
83+
### Configuration Matrix
84+
85+
The behavior depends on the combination of `UseProvidedChatClientAsIs` and `PersistChatHistoryAtEndOfRun`:
86+
87+
| `UseProvidedChatClientAsIs` | `PersistChatHistoryAtEndOfRun` | Behavior |
88+
|---|---|---|
89+
| `false` (default) | `false` (default) | **Per-service-call persistence.** A `ChatHistoryPersistingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. |
90+
| `true` | `false` | **User responsibility.** No middleware is injected because the user has provided a custom chat client stack. The user is responsible for ensuring correct persistence behavior (e.g., by including their own persisting middleware). |
91+
| `false` | `true` | **Per-run persistence with marking.** A `ChatHistoryPersistingChatClient` middleware is injected, but configured to *mark* messages with metadata rather than store them immediately. At the end of the run, marked messages are stored. Trailing `FunctionResultContent` is trimmed. |
92+
| `true` | `true` | **Per-run persistence with warning.** The system checks whether the custom chat client stack includes a `ChatHistoryPersistingChatClient`. If not, a warning is emitted (particularly relevant for workflow handoff scenarios where trimming cannot be guaranteed). If no `ChatHistoryPersistingChatClient` is preset, all messages are stored at the end of the run, otherwise marked messages are stored. |
93+
94+
### Consequences
95+
96+
- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying consistency (driver A).
97+
- Good, because intermediate progress is preserved if the process is interrupted, satisfying recoverability (driver C).
98+
- Good, because no separate `FunctionResultContent` trimming logic is needed in the default path, reducing complexity.
99+
- Good, because marking persisted messages with metadata enables deduplication and aids debugging.
100+
- Good, because warnings for custom chat client configurations without the persisting middleware help prevent silent failures in workflow handoff scenarios.
101+
- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases.
102+
- Bad, because the mental model is more complex for the default path: a single run may produce multiple history updates.
103+
- Neutral, because users who prefer atomic run semantics can opt in to per-run persistence via `PersistChatHistoryAtEndOfRun = true`.
104+
- Neutral, because increased write frequency from per-service-call persistence may impact performance for some storage backends; this can be mitigated with a caching decorator.
105+
106+
### Implementation Notes
107+
108+
#### Conversation ID Consistency
109+
110+
The `ChatHistoryPersistingChatClient` middleware must also update the session's `ConversationId` consistently for both response-based and conversation-based service interactions, ensuring the session always reflects the latest service-provided identifier.
111+
112+
## More Information
113+
114+
- [PR #4762: Persist messages during function call loop](https://github.com/microsoft/agent-framework/pull/4762) — introduces `PersistChatHistoryAfterEachServiceCall` option and `ChatHistoryPersistingChatClient` decorator
115+
- [PR #4792: Trim final FRC to match service storage](https://github.com/microsoft/agent-framework/pull/4792) — introduces `StoreFinalFunctionResultContent` option and `FilterFinalFunctionResultContent` logic
116+
- [Issue #2889](https://github.com/microsoft/agent-framework/issues/2889) — original issue tracking chat history persistence during function call loops

dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
#region Setup Telemetry
2020

21+
// Source name for this sample's custom ActivitySource and Meter; other instrumentation uses their own sources/categories.
2122
const string SourceName = "OpenTelemetryAspire.ConsoleApp";
2223
const string ServiceName = "AgentOpenTelemetry";
2324

@@ -40,7 +41,6 @@
4041
var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder()
4142
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"))
4243
.AddSource(SourceName) // Our custom activity source
43-
.AddSource("*Microsoft.Agents.AI") // Agent Framework telemetry
4444
.AddHttpClientInstrumentation() // Capture HTTP calls to OpenAI
4545
.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint));
4646

@@ -54,8 +54,7 @@
5454
// Setup metrics with resource and instrument name filtering
5555
using var meterProvider = Sdk.CreateMeterProviderBuilder()
5656
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"))
57-
.AddMeter(SourceName) // Our custom meter
58-
.AddMeter("*Microsoft.Agents.AI") // Agent Framework metrics
57+
.AddMeter(SourceName) // Our custom meter source
5958
.AddHttpClientInstrumentation() // HTTP client metrics
6059
.AddRuntimeInstrumentation() // .NET runtime metrics
6160
.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint))
@@ -128,7 +127,7 @@ static async Task<string> GetWeatherAsync([Description("The location to get the
128127
instructions: "You are a helpful assistant that provides concise and informative responses.",
129128
tools: [AIFunctionFactory.Create(GetWeatherAsync)])
130129
.AsBuilder()
131-
.UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
130+
.UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
132131
.Build();
133132

134133
var session = await agent.CreateSessionAsync();

dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,28 @@
7373
using JsonDocument getConversationItemsResultAsJson = JsonDocument.Parse(result.GetRawResponse().Content.ToString());
7474
foreach (JsonElement element in getConversationItemsResultAsJson.RootElement.GetProperty("data").EnumerateArray())
7575
{
76+
// Skip non-message items (e.g. tool calls, reasoning) that lack a "role" property
77+
if (!element.TryGetProperty("role"u8, out var roleElement))
78+
{
79+
continue;
80+
}
81+
7682
string messageId = element.GetProperty("id"u8).ToString();
77-
string messageRole = element.GetProperty("role"u8).ToString();
83+
string messageRole = roleElement.ToString();
7884
Console.WriteLine($" Message ID: {messageId}");
7985
Console.WriteLine($" Message Role: {messageRole}");
8086

81-
foreach (var content in element.GetProperty("content").EnumerateArray())
87+
if (element.TryGetProperty("content"u8, out var contentElement))
8288
{
83-
string messageContentText = content.GetProperty("text"u8).ToString();
84-
Console.WriteLine($" Message Text: {messageContentText}");
89+
foreach (var content in contentElement.EnumerateArray())
90+
{
91+
if (content.TryGetProperty("text"u8, out var textElement))
92+
{
93+
Console.WriteLine($" Message Text: {textElement}");
94+
}
95+
}
8596
}
97+
8698
Console.WriteLine();
8799
}
88100
}

dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,11 @@
1616
<ItemGroup>
1717
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
1818
</ItemGroup>
19+
20+
<ItemGroup>
21+
<None Update="Assets\walkway.jpg">
22+
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
23+
</None>
24+
</ItemGroup>
1925

2026
</Project>
37.1 KB
Loading

dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
ChatMessage message = new(ChatRole.User, [
2424
new TextContent("What do you see in this image?"),
25-
new UriContent("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "image/jpeg")
25+
await DataContent.LoadFromAsync("Assets/walkway.jpg"),
2626
]);
2727

2828
var session = await agent.CreateSessionAsync();

dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
ChatMessage message = new(ChatRole.User, [
2626
new TextContent("What do you see in this image?"),
27-
await DataContent.LoadFromAsync("assets/walkway.jpg"),
27+
await DataContent.LoadFromAsync("Assets/walkway.jpg"),
2828
]);
2929

3030
AgentSession session = await agent.CreateSessionAsync();

dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
namespace WorkflowAsAnAgentSample;
1010

1111
/// <summary>
12-
/// This sample introduces the concepts workflows as agents, where a workflow can be
12+
/// This sample introduces the concept of workflows as agents, where a workflow can be
1313
/// treated as an <see cref="AIAgent"/>. This allows you to interact with a workflow
1414
/// as if it were a single agent.
1515
///
@@ -18,6 +18,14 @@ namespace WorkflowAsAnAgentSample;
1818
///
1919
/// You will interact with the workflow in an interactive loop, sending messages and receiving
2020
/// streaming responses from the workflow as if it were an agent who responds in both languages.
21+
///
22+
/// This sample also demonstrates <see cref="IResettableExecutor"/>, which is required
23+
/// for stateful executors that are shared across multiple workflow runs. Each iteration
24+
/// of the interactive loop triggers a new workflow run against the same workflow instance.
25+
/// Between runs, the framework automatically calls <see cref="IResettableExecutor.ResetAsync"/>
26+
/// on shared executors so that accumulated state (e.g., collected messages) is cleared
27+
/// before the next run begins. See <c>WorkflowFactory.ConcurrentAggregationExecutor</c>
28+
/// for the implementation.
2129
/// </summary>
2230
/// <remarks>
2331
/// Pre-requisites:
@@ -39,7 +47,10 @@ private static async Task Main()
3947
var agent = workflow.AsAIAgent("workflow-agent", "Workflow Agent");
4048
var session = await agent.CreateSessionAsync();
4149

42-
// Start an interactive loop to interact with the workflow as if it were an agent
50+
// Start an interactive loop to interact with the workflow as if it were an agent.
51+
// Each iteration runs the workflow again on the same workflow instance. Between runs,
52+
// the framework calls IResettableExecutor.ResetAsync() on shared stateful executors
53+
// (like ConcurrentAggregationExecutor) to clear accumulated state from the previous run.
4354
while (true)
4455
{
4556
Console.WriteLine();

0 commit comments

Comments
 (0)