|
| 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 |
0 commit comments