From 20f9fd19922c1978850c5b69255addf41ad97db3 Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Fri, 4 Sep 2026 14:19:38 -0500 Subject: [PATCH 01/13] docs: add ADR 0032 for durable thread compaction --- .../0032-durable-thread-compaction.md | 679 ++++++++++++++++++ 1 file changed, 679 insertions(+) create mode 100644 docs/decisions/0032-durable-thread-compaction.md diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md new file mode 100644 index 0000000..14dbf08 --- /dev/null +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -0,0 +1,679 @@ +--- +status: proposed +contact: ahmedmuhsin +date: 2026-07-27 +deciders: +consulted: +informed: +--- + +# Thread Compaction for Durable Agents and Workflows + +> **How to read this.** The decision comes first. The later sections record the Python prototype +> and the gaps it exposed. **.NET is not implemented yet.** +> +> **Naming.** .NET's `ChatHistoryProvider` and Python's `HistoryProvider` are the same concept. The +> decision sections use the .NET name, and the implementation sections use the Python one. + +## Context and Problem Statement + +Long-running **durable** agents and workflows accumulate conversation history in durable +storage and replay it on every turn. Durable agents persist a full `ConversationHistory` in +entity state (`AgentEntity` → `DurableAgentState`). Durable workflows persist inter-executor +messages (`AgentExecutor.full_conversation`) as checkpointed envelopes. An in-memory agent keeps its +history in process RAM, where it disappears when the process recycles. Durable history instead +survives restarts and is reloaded on later turns. + +It helps to separate **three distinct pressures**, because they have different owners. + +| Pressure | What bounds it | Same in core? | Owner | +| --- | --- | --- | --- | +| **Context window**, the model's max input per call | the model | **Yes**, identical in core and durable | Compaction (in-run filter) | +| **Token cost / latency**, resending history each turn | tokens billed / round-trip | **Yes**, same mechanism | Compaction (in-run filter) | +| **Storage capacity**, the cumulative persisted state | backend state-size limit | **No**, durable-only | Backend offload and durable retention | + +The first two are per-operation and identical in both runtimes. The third is cumulative. +`ConversationHistory` is one blob appended to every turn and re-persisted whole, so it is bounded by +what the backend will store, and the two backends fail differently. **Durable Task Scheduler caps a +message at 1 MB.** The Azure Storage backend has no hard cap, because it compresses anything over +45 KB into a `-largemessages` blob, but it pays for size in CPU, I/O and memory. So one +backend stops working at the limit and the other degrades toward it, while a core process is bounded +only by RAM and resets on restart. + +**Storage capacity is an infrastructure concern, not a context-window concern.** It is relieved +first by raising the ceiling where blob offload is available and only then by deleting. A tool for +bounding what the model reads is not a tool for bounding what the backend holds. + +Core MAF already has a compaction system ([ADR-0019](https://github.com/microsoft/agent-framework/blob/main/docs/decisions/0019-python-context-compaction-strategy.md), +.NET `Microsoft.Agents.AI.Compaction`, Python `agent_framework._compaction`) with **two hooks**. + +1. **In-run filter.** A `CompactionProvider` (`AIContextProvider`) / `compaction_strategy` runs + before each model call. It is **non-lossy**. It filters the projection sent to the model and + stores incremental group state in the `AgentSession.StateBag`, leaving the underlying store + untouched. This hook works with **any** history provider, since it acts on the messages already + loaded into the invocation context. + + Non-lossy has a storage consequence worth stating, because durable is where it is first felt. A + strategy that marks messages excluded and appends a summary standing in for them **grows** the + stored conversation: the originals remain and the summary is added. Measured over six turns, a + durable conversation went from 3,422 bytes to 10,177 with such a strategy enabled. This is not + something the durable layer introduces. The identical strategy against core's own + `InMemoryHistoryProvider` grew 809 bytes to 1,087. Durable only changes the consequence, because + it persists the result against a hard backend limit rather than holding it in process memory. + `retention="follow_compaction"` is the answer for anyone who wants compaction without paying for + it in storage: the same six turns end at 2,296 bytes, below the 3,422 they would have reached + with no compaction at all. Note that `auto`, the default, does **not** bound this growth. It + reacts to pressure rather than to compaction, so it behaves identically to `keep_all` until the + budget is approached. +2. **Store reducer.** **Lossily** rewrites the stored conversation, applying the same strategies at + the store instead of at the model call. Unlike the in-run filter, this hook is tied to a specific + storage mechanism in both languages. .NET exposes an `IChatReducer` on `InMemoryChatHistoryProvider` + only (bridged from any strategy by `strategy.AsChatReducer()`), and Python's + `CompactionProvider.after_strategy` reads the messages out of session state. Neither offers it to + a provider backed by anything else - see "Core Interface Gaps" below. + +The durable layer benefited from **neither**, because `AgentEntity` **bypassed the history +provider**: it created a fresh session per operation (so the StateBag - and any history provider +store or reducer in it - was discarded) and fed `ConversationHistory` directly as input messages. +Both the in-run filter's incremental state and the store reducer were thrown away every turn. + +The goal is **configuration parity**: a user's core compaction config must carry over to a durable +entity or workflow **unchanged**, reusing the same strategies and hooks on the durable runtime, +without a parallel durable compaction API. Storage retention is a separate deployment policy because +it has no meaning for an in-memory agent. + +**How should core compaction be reused on the durable runtime, in both .NET and Python, so the same +agent configuration bounds model input and the persisted store can be bounded separately?** + +## Decision Drivers + +- **Configuration parity.** The same core compaction config (strategies, `CompactionProvider`, + `IChatReducer`) must apply unchanged when moving core → durable entity → durable workflow. No + parallel durable-only API. +- **Reuse existing core hooks.** Do not reinvent triggers, strategies or grouping. Reuse the in-run + filter and the store reducer. +- **Separate storage capacity from context management.** Bound the model input with compaction + (parity with core), raise backend capacity where possible, and use observable deletion only as a + fallback. +- **Deleting is a last resort, and never silent.** Entity state is a state bag, not an immutable + system of record, so deleting from it is legitimate. But deletion should happen only when capacity + demands it, should remove no more than capacity demands, and should always be observable. +- **Determinism and idempotency.** Durable entity operations can be retried, so a lossy reducer + (especially LLM summarization) must not corrupt or diverge persisted state across retries. +- **Message-list correctness.** Preserve atomic groups (assistant tool-call plus tool-result, and + reasoning pairings) so the model input stays valid. +- **Cover both surfaces.** Durable agents **and** durable workflows, in **both** languages. Core's + compaction system is agent-level, so a workflow agent node inherits it unchanged. The conversation + chained *between* nodes is governed by `AgentExecutor`'s `context_mode` / `context_filter` seam, + which is a plain callable rather than the compaction system. That difference is real and is called + out rather than papered over. +- **Defer when the model provider owns the conversation.** When the chat client keeps history on the + service, the client holds no history for core compaction. "Service" here means the model provider, + not the durable entity. The entity's own durable record still follows its retention policy. + +## Considered Options + +- **Option 1, in-run filter only (rejected).** Reuse the agent's core compaction without changing + durable history. This bounds model input but not persisted state. +- **Option 2, bespoke pre-write compaction in the agent entity.** Add durable-specific code that + compacts `ConversationHistory` inside the entity operation before checkpoint. Rejected because it + duplicates core's store-reducer behavior. +- **Option 3, on-storage maintenance compaction (deferred).** Compact persisted history from a + separate entity operation. This may suit expensive summarization but does not prevent in-turn + growth. +- **Option 4, workflow context projection (chosen).** Honor `AgentExecutor.context_mode` and + `context_filter` for the `full_conversation` chained between executors. +- **Option 5, auto-derive a durable store reducer (rejected as default).** Derive a lossy reducer + from a configured in-run strategy. The explicit equivalent is `follow_compaction`. The default + must also protect agents with no compaction strategy. +- **Option 6, durable store as a `ChatHistoryProvider` (chosen).** Back the durable entity's + persisted conversation with a core `ChatHistoryProvider` implementation, so both core hooks apply + on the durable runtime from the user's unchanged configuration. The in-run filter runs in the + agent pipeline (L1), and a user-configured reducer or strategy can bound the durable store (L2, + opt-in). External history providers also rejoin the context pipeline, and the entity stops + keeping their content, so each store bounds only what it owns. +- **Option 7, offload large payloads to blob storage (backend-specific, not part of the portable + design).** Raise the ceiling instead of reducing content, using the Durable Task Scheduler [large + payload extension](https://learn.microsoft.com/azure/durable-task/scheduler/durable-task-scheduler-large-payloads). + Non-lossy, and the same technique the Azure Storage backend has always used internally. + + Deliberately **not** counted as part of the chosen design, because it is not available everywhere. + It adds an Azure Blob payload-store dependency, the Azure Storage backend already does the + equivalent internally so configuring it there is redundant, and Durable Functions Python cannot + opt in at all today (gap 6). Treat it as an optimization a specific deployment may enable, detected + rather than assumed: nothing in this design may depend on it being present, and `max_state_bytes` + stays at the unoffloaded limit unless a deployment raises it deliberately. Retention is what has to + be correct on every host, and is specified without reference to offload. + +## Decision Outcome + +Chosen option: **Option 6, express durable conversation storage as a core `ChatHistoryProvider`**, +combined with workflow context projection (Option 4). The two solve different surfaces. + +| Surface | Mechanism | Behavior | +| --- | --- | --- | +| **L1, agent context** | The user's configured core `CompactionProvider` / `compaction_strategy` | Non-lossy projection of model input. The same agent configuration works durably. | +| **L2, eager store pruning** | Core compaction annotations plus `retention="follow_compaction"` | Opt-in deletion of messages the user's strategy excluded. Python only today because .NET is blocked by duplicated compaction state (gap 4). | +| **L3, workflow context** | Existing `AgentExecutor.context_mode` / `context_filter` projection | Controls `full_conversation` passed between executors. This is not a core compaction hook. | +| **Capacity fallback** | Durable retention | Bounds entity state independently of whether compaction is configured. | + +This gives agent-level **configuration parity**, not byte-for-byte parity in every workflow cycle. +Durable workflow nodes intentionally deduplicate repeated upstream context before persisting it. The +L3 section explains the measured difference. + +Capacity is handled in this order: choose a workflow context mode that does not carry the whole +conversation, raise the ceiling non-lossily where blob offload is available, honor an explicit +`follow_compaction` choice, then evict under pressure. An exclusion normally means only "do not send +this to the model". It means "delete this" only under `follow_compaction`. + +### Who bounds what + +Every store bounds what it owns. This is the rule the rest of this section follows, and it is worth +stating plainly because it decides which copy of a conversation is authoritative. + +| Where the conversation lives | What bounds it | What the entity keeps | +| --- | --- | --- | +| The customer's own store (Redis, Cosmos, file) | Their store's own policy, for example Redis `max_messages` or a Cosmos container TTL | The exchange, not the content | +| Durable entity state | Durable retention, described below | Everything, since nothing else holds it | +| The model service | The service's own retention | The exchange, not the content | +| No context pipeline at all | Durable retention | Everything, since nothing else holds it | + +The entity records every exchange in every configuration, because correlation ids and response +delivery are its job and nothing else can do them. It does not have to be a second copy of the +conversation, and being one would put the customer's content under two different retention, +residency and deletion policies when they deliberately chose one store for it. So when another +provider owns the conversation, the entity keeps the envelope, the correlation id, the timestamps +and the message ids, and forgets the content. + +**Responses are the exception, deliberately.** A caller collects its answer by polling this entity +for a correlation id, so the entity is the only thing that can produce it. Response content is +therefore retained regardless of who owns the conversation. + +That retention is not merely a cost. An entity signal is one-way, so for the client and HTTP paths +the recorded response *is* the return value, and persisting it is what lets a caller that crashed +between the agent finishing and the poll landing still collect a result whose model tokens and tool +side effects have already been paid for. It also makes the request answered **once**: signals are +delivered at least once and every path mints a fresh correlation id per request, so a repeated id is +a duplicate delivery rather than a caller asking again. The entity returns the recorded answer +instead of running the agent a second time. Before that check existed, a duplicate spent another +model call and produced a second, different answer that nothing could collect, since pollers take +the first match for a correlation id. + +Orchestrations reach the entity through `call_entity` instead, which returns the value directly. The +same bytes then exist in two places, but they are not two copies of one thing: the orchestrator +records a **task result**, which is what makes its replay deterministic, while the entity records +**what the assistant said**, which is what the next turn's model context is built from. Neither is +removable, and the overlap is two systems recording the same event for different reasons rather than +a defect in either. + +**Ownership is resolved per run, not per registration.** `store` is an ordinary run option, so an +agent registered against a service-storing client can still be asked to keep a single turn +client-side. A durable history provider is therefore attached in that case too, claiming the slot +before core can inject one of its own whose state would be persisted with the entity and invisible +to retention. The provider yields no history on runs the service does own, so the model is never +sent a transcript the service is already carrying. + +### Retention + +| Mode | Behavior | +| --- | --- | +| `keep_all` | Never delete. The entity may reach the backend limit and fail. | +| `auto` **(default)** | Delete only under storage pressure, targeting the low watermark. | +| `follow_compaction` | Delete whatever compaction excluded every turn, then use the same pressure eviction as `auto` if the remaining state is still too large. | + +**Why a deleting default.** `auto` means the runtime may remove customer conversation content +without being asked, which deserves an argument rather than an assumption. The alternative is +`keep_all`, and its failure mode is worse: the entity reaches the backend limit and then cannot be +written to at all, so the agent stops answering and the conversation is unrecoverable rather than +merely shortened. Since eviction is oldest-first and stops at the low watermark, `auto` trades the +oldest part of a conversation for the session continuing to work. That is the right default for a +runtime whose purpose is durability, but only because it is bounded, ordered, and recorded: the +newest exchange and any response a caller may still be reading are never evicted, and the +`truncation` record means the loss is discoverable afterwards. `keep_all` remains available for +callers who would rather fail than forget. + +**How pressure eviction works.** After the turn is recorded and before the state is persisted, the +entity measures its serialized state. `auto` uses only this path. `follow_compaction` uses it after +eager pruning. Below the high watermark, nothing happens. Above it, the entity targets the low +watermark using detached message copies with existing exclusions cleared and +`TokenBudgetComposedStrategy(strategies=[])`. Clearing exclusions makes the budget reflect what is +stored, while the empty strategy list bypasses the user's context policy and uses core's +deterministic oldest-group fallback. Atomic tool groups and the newest exchange are protected, as +are responses recent enough that their caller may still be polling for them. The entity remeasures +after each pass. + +**The budget is derived from bytes, not from text.** The constraint is a byte limit but the strategy +counts tokens, so the conversion is measured from the messages in hand: the persisted size of the +evictable messages against their token count, with everything unevictable treated as a floor the +budget cannot reach below. Deriving it from `message.text` instead would make it depend on the +*kind* of content rather than its size, and a function call has no text at all, so a tool-only +conversation would produce a budget of one token and evict everything it was allowed to touch. + +**System messages are held out of the candidate set** rather than left to core's protection. Core +skips system groups in its first fallback, but it has a second, strict fallback whose purpose is to +evict them once anchors alone exceed the budget. Relying on the first therefore holds only until the +budget is small enough to matter. Excluded from the candidates, the agent's instructions are simply +not evictable, and their bytes count toward the floor. + +**Eviction leaves durable evidence.** A `truncation` record beside the conversation holds a count and +the first and last eviction times. A log line is evidence to whoever was watching at the time and to +nobody afterwards, which is no use to a user asking later why an answer lost context. It is a counter +rather than a list of what was removed, because such a list would grow without bound in exactly the +situation retention exists to resolve. Its absence is meaningful: it says nothing has been dropped. + +`max_state_bytes` defaults to `1_048_576`. High and low watermarks of `0.85` and `0.70` provide +hysteresis and room for estimation error. Measuring a 1 MB prototype state took about 8 ms. + +**Why not simply reduce the store by default.** A default-on reducer only helps agents that already +configured compaction, because nothing else marks messages excludable. It would leave every other +configuration exposed while changing behavior for only a subset of users. + +**Why not rely on blob offload alone.** It raises the ceiling roughly tenfold and does not remove it. +It is also unreachable on the Durable Functions Python path today (gap 6). Retention is therefore +the fallback that works on every host. + +**Service-managed model context** is outside compaction scope, mirroring ADR-0019. When the model +provider owns the conversation, the client holds no history to compact. Entity retention still +applies. See "Service-managed conversations". + +**Why workflows largely come "for free."** Durable workflow agent execution +(`DurableExecutorDispatcher.ExecuteAgentAsync`) runs an agent through the same +`DurableAIAgent → AgentEntity → inner agent` path as standalone durable agents, so **L1, L2 and +retention are inherited by workflow agent executors**. The workflow's own `full_conversation` between +executors does not pass through the agent, so it needs the separate **L3** hook. + +### Consequences + +- **Configuration parity.** Existing agent compaction configuration works durably without changing + the agent. Retention does not choose the current model projection. +- **Broad capacity protection.** Because pressure eviction lives in the entity, it covers external + providers, service-managed agents, and agents with no context pipeline. A single oversized newest + exchange can still fail because the current result is never evicted. +- **Proportionate deletion.** Under `auto`, the budget decides how much to remove. The user's context + strategy does not. +- **Larger entity change.** The history-provider design must preserve response polling and the + entity's conversation record. +- **Python-only eager pruning.** .NET would duplicate the transcript if it persisted current + compaction state (gap 4), so a .NET implementation could use pressure retention but not L2 yet. +- **Core workarounds.** Python must publish and reconcile a working buffer because core binds + store-side compaction to session state (gaps 1 and 2). +- **Threshold behavior.** `auto` changes behavior only near the capacity limit. This is less uniform + than always pruning, but it avoids changing unaffected conversations. + +### Validation + +**Done (Python).** Unit tests cover provider substitution, annotation round-trips, synthetic summary +insertion and reconciliation, all retention modes, session persistence, and workflow projection and +deduplication. The retention test drives a real agent through twenty turns against a reduced budget. +`keep_all` is the control proving the same run exceeds it. Scheduler integration covers persisted +annotations and message ids, external-provider session identity, schema conformance, and downstream +workflow context. + +**Outstanding.** Not covered yet. + +- Retention crossing the real scheduler limit against a live backend, rather than a reduced budget + in process. +- The .NET realization and its schema parity (gap 3), and the .NET compaction-state blocker (gap 4). +- Blob offload (Option 7) against a real scheduler. It remains unreachable through Durable Functions + Python 1.x and the 2.x preview (gap 6). +- Idempotency of an LLM-based reducer across simulated entity retries. + +## Cross-Cutting Design Details + +- Honor the user's configured reducer trigger. Durable registration must not change compaction + cadence. +- Reuse core grouping so tool-call/result and reasoning groups remain atomic. +- Pressure eviction is deterministic and uses the estimator tokenizer without a model call. Any + future LLM reducer must give summaries stable identities and be tested across retries. +- The durable history provider belongs in `AgentEntity`. Workflow projection belongs at the + existing `AgentExecutor.context_mode` / `context_filter` seam. + +## Core Interface Gaps for Pluggable History Providers + +Prototyping the Python `DurableHistoryProvider` surfaced places where the current contracts assume a +*session-state-backed* history provider. They are recorded here because they affect **any** provider +whose store is not session state (Cosmos, Valkey, durable), not just this one. The prototype works +around them, but the cleaner fix is upstream. + +These are scoping decisions rather than oversights, and worth reading that way. Store-rewrite +compaction reaches the one store whose lifetime core controls, and the providers core ships for +other stores bound themselves instead: `RedisHistoryProvider` takes `max_messages` and trims with +`ltrim`, and a Cosmos container has its own TTL. That is the same layering this ADR follows, each +store bounding what it owns. What is missing is not the capability but a way to *express* it through +the provider abstraction, which is what makes it a prerequisite rather than a tidy-up. + +1. **Store-side compaction is bound to session state rather than to the provider.** `CompactionProvider` + has two hooks, but only `before_strategy` works with any provider because it acts on invocation + context. `after_strategy` mutates `session.state[history_source_id]["messages"]` and assumes that + mutation rewrites storage. External providers can therefore bound model input but cannot use + core to rewrite their stores. .NET similarly exposes `IChatReducer` only on + `InMemoryChatHistoryProvider`. + + The cost today is silence rather than failure: a user who wires `after_strategy` to Redis or + Cosmos gets no annotations, no summaries, no error and no warning. Verified against core's own + `FileHistoryProvider`, whose `save_messages` begins `del state, kwargs`: the identical strategy + that produced 11 exclusions and 4 summaries under `InMemoryHistoryProvider` produced none. + + *Workaround:* the durable provider publishes a working buffer under the session-state key core + expects. *Upstream fix:* put store-rewrite compaction on the provider abstraction, and in the + meantime say something when the hook cannot reach the configured store. + +2. **`save_messages()` is append-only.** The other half of the same open question. It receives only + new messages, so changes to existing messages and inserted summaries have no path back to storage. + Confirmed against shipping code rather than argued in the abstract: `RedisHistoryProvider` + persists with `rpush`, which can express "add" and nothing else, so even a provider-level + compaction hook would have no way to say "replace this" or "drop these". + *Workaround:* the durable provider reconciles its working buffer **by `message_id`** during + `after_run`. *Upstream fix:* add an explicit replace/flush operation alongside append. + + Gaps 1 and 2 together are a **prerequisite for treating an external provider as the sole store of + a compacted conversation**, not an upstream cleanup note. Until they are closed, a customer's own + store can bound what the model reads but cannot be rewritten by the compaction they configured, + and the durable runtime can only offer that capability for conversations it holds itself. + + **The workaround against the contract it should become.** Today the durable provider publishes a + working buffer under the session-state key `after_strategy` reads, then reconciles the result back + by `message_id`. That works, but it only works because the provider is willing to impersonate + session-state storage. It is invisible to any provider that does not know the trick, it silently + does nothing for the ones core itself ships for Redis and Cosmos, and it couples us to a key whose + shape core is free to change. An additive contract on the provider, `replace_messages()` plus + `flush()` alongside the existing append, would let core drive store rewrite through the + abstraction instead, make the capability discoverable, and remove the impersonation. + + **What must land upstream before cross-language parity is complete**, stated so it can be checked + rather than argued: + + 1. Store rewrite expressed on the provider abstraction, so `after_strategy` reaches any store. + 2. A replace/flush operation alongside append, so summaries and annotations have a path back. + 3. Core exposing its **resolved** service-versus-client history ownership. Durable currently + re-derives it from `store` and `STORES_BY_DEFAULT`, which duplicates a decision core has + already made and will drift the moment core's precedence changes. + 4. .NET reaching the same point, which additionally needs `MessageId` and + `AdditionalProperties` to survive `FromChatMessage`/`ToChatMessage` (gap 3). + + Items 1 to 3 are the same contract work and should be designed together. + +3. **Message-level metadata was not persisted (durable schema).** Python wrote + `extension_data` asymmetrically, so annotations disappeared on round-trip. This is fixed. The + shared schema now declares `messageId` and `extensionData` as round-trip-required, describes + `session` as runtime-discriminated, and has a conformance test. A validator would not previously + have rejected these fields because the schema permits extra properties. The defect was an + under-declared contract. + + .NET still loses `ChatMessage.AdditionalProperties` and `MessageId` in + `FromChatMessage`/`ToChatMessage`. Its `[JsonExtensionData]` property is only an overflow bucket for + unmapped JSON. `ChatMessage.MessageId` **does** exist in the pinned package and needs mapping. + Exclusions themselves live on `CompactionMessageGroup.IsExcluded`, while + `AdditionalProperties` carries the `_is_summary` marker, so mapping both fields is necessary but + not sufficient for .NET compaction parity. + +4. **.NET compaction state cannot be persisted without duplicating the transcript.** This is the + blocker behind "L2 is Python-only today". `CompactionProvider.State` is documented as living in + `AgentSession.StateBag`, holds `List`, and each group serializes its full + `ChatMessage` objects. Every run rewrites it wholesale. That leaves three unappealing choices for a + durable provider that also persists the session: + + | Choice | Consequence | + | --- | --- | + | Persist the session | The conversation is stored twice, in `ConversationHistory` and again in the state bag, so entity state roughly doubles instead of being bounded | + | Omit the provider state | Exclusions and summaries are discarded and summarization can re-run | + | Return only included messages | `CompactionMessageIndex.Update()` sees a trimmed front and rebuilds from scratch, losing the incremental state | + + None of this is inherent to the history-provider approach. It resolves if core can persist + lightweight compaction metadata keyed by `MessageId` rather than whole message copies. Until then + a .NET implementation can bound entity state through the retention path, which is independent of + `CompactionProvider`. + +5. **Provider cadence splits under per-service-call persistence.** With + `require_per_service_call_history_persistence=True`, history providers run per model call while + `CompactionProvider` remains once per run. Compaction can then annotate after the last history + flush, delaying persistence until the next flush. Only `HarnessAgent` enables this today, so the + gap is latent. + +6. **Blob offload is unreachable on the Durable Functions Python path.** Not a core gap but an + upstream gap. Version 1.x, which this package pins (`>=1.3.1,<2`), does not use the durabletask + SDK, so it has no Python-side payload-store seam. The 2.x previews (`2.0.0b1` and `2.0.0b2`, Python + 3.13+) depend on `durabletask>=1.9.0`, but `DurableFunctionsWorker` and + `DurableFunctionsClient` do not expose the base types' `payload_store` parameter. The direct + durabletask path does. *Upstream fix:* expose `payload_store` on both Functions types. + +Two more core gaps are described where they matter: the process-local state-type registry under +session persistence, and the lack of a public resolved history-ownership decision under +service-managed conversations. + +## L3 Realization: Workflow Context Parity + +In-process workflows give a downstream `AgentExecutor` the upstream conversation through +`AgentExecutorResponse.full_conversation`, governed by `context_mode` (`full` | `last_agent` | +`custom` + `context_filter`). The durable orchestrator previously flattened that to the **last +message's text**, so a downstream agent lost everything earlier nodes produced. + +Agent-level compaction needs no workflow-specific work: `AgentExecutor` passes its own session to +`agent.run()`, so the agent's `CompactionProvider` runs normally. Inter-executor context has no core +compaction hook. Durable instead honors the existing `context_mode` and invokes `context_filter` for +`custom` mode, then sends the projection as `RunRequest.context_messages`. Those messages become part +of the request entry and are visible to agent-level compaction. + +### `context_filter` must be pure under durable + +Core types the filter as `Callable[[list[Message]], list[Message]]` and requires nothing more, +because an in-process executor runs it exactly once. A durable orchestrator does not resume, it +**re-executes from the top** on every episode, returning recorded results for work already done. The +projection is computed in that re-executed code, so the filter runs again on every replay: roughly +once per node in a sequential workflow, and again each time a workflow parked on a human decision +wakes up. + +**The durable contract is therefore stricter than core's.** A `context_filter` must be synchronous, +deterministic, free of side effects, and independent of wall-clock time, randomness, and any +external state. `full` and `last_agent` satisfy this by construction, since they are list slicing. +Only `custom` can violate it. + +Violating it fails **softly**, which is worth stating precisely so the risk is neither overstated +nor dismissed. The Durable Task worker detects non-determinism by checking that an action exists at +the expected id and is of the expected kind; it never compares the action's input. The projection is +only ever an input, and nothing branches on it. So a filter that returns something different on +replay does not raise `NonDeterminismError` and does not deliver altered context to an agent. The +recomputed value is discarded and the recorded result stands. + +What does bite: + +- A filter with side effects performs them again on every replay, so one logical handoff can write + many audit entries. +- A filter that performs I/O can raise on a later replay, failing an orchestration whose original + run succeeded and whose result is already recorded. +- A slow filter is paid for on every episode rather than once. + +**Why the filter runs there at all.** Someone has to apply the projection, and the placement is a +trade. Applying it in the orchestrator keeps only the projection on the wire, which is what makes +`context_mode` an effective capacity lever. Applying it at the destination would keep user code out +of replayed territory but put the whole conversation back on the wire. Applying it inside an +activity would achieve both at the cost of a scheduling round trip per handoff. The current design +takes the first, and the contract above is the price. Revisiting that, along with replacing the +private `_context_mode` / `_context_filter` reads with a public accessor, is tracked in +[#79](https://github.com/microsoft/agent-framework-durable-extension/issues/79). + +Cycles need deduplication because a node receives the accumulated upstream conversation again on +each visit. The orchestrator stamps each forwarded message as `wf_{executor}_{position}`. The entity +stores the highest ingested position per executor and drops older positions, keeping the newest +message as input when everything repeats. Per-executor watermarks are required because fan-out +branches can share a position. + +### Context mode is the first answer to workflow capacity + +The projection is what grows with the conversation, and it is already a choice the workflow author +makes. Measured, serialized, as the conversation lengthens: + +| Turns | `full` (default) | `last_agent` | `custom`, last 4 messages | +| ---: | ---: | ---: | ---: | +| 10 | 8,370 | 837 | 1,674 | +| 50 | 42,010 | 841 | 1,682 | +| 200 | 168,560 | 845 | 1,690 | +| 800 | 675,560 | 845 | 1,690 | + +At 800 turns `full` is 64.4% of the 1 MB limit while `last_agent` is 0.1%. The difference is not a +smaller payload, it is a payload that stops growing: `last_agent` and a fixed-window `custom` filter +are both constant regardless of conversation length. + +So a workflow that approaches the limit through its own projection should change mode before +reaching for offload or retention, because those manage a cost this removes. `full` remains the +default to match core, and it is the right choice when downstream nodes genuinely need the whole +history, but it is a deliberate choice with a measurable price rather than a free default. + +Stored-id comparison is insufficient: retention removes old ids, after which a cycle would re-ingest +exactly what was evicted and oscillate instead of converging. The small position map survives +deletion. Once content is evicted, the node no longer sees it. Re-ingesting it would defeat +retention. + +This intentionally differs from core in one measured case. On the third visit of a `full`-mode +cycle, core in-process sends 11 messages with repeated context while durable sends 8 after dedup. In +`last_agent` mode they are identical. Each durable node also keeps history keyed by workflow instance +and executor, so its memory survives restarts independently of the workflow envelope. + +## Zero-Configuration Registration + +Registration must not require edits to an agent that already works in core. The entity therefore +substitutes history at construction time. It shallow-copies the agent when substitution is needed, +so the caller's instance remains unchanged. + +| User configured | Durable behavior | +| --- | --- | +| Nothing | Inject a durable history provider, using the `source_id` core's auto-injected provider would have, so default-wired compaction still resolves. No compaction by default (same as core). | +| `InMemoryHistoryProvider` (± compaction) | Replace with the durable provider, **preserving `source_id` and `skip_excluded`** so any attached `CompactionProvider` keeps working untouched. | +| `DurableHistoryProvider` wired by hand | Keep it. Rebuild it with the retention mode's pruning only when `prune_excluded` was left unset, since an unset value is the absence of an opinion rather than a decision. A pinned value wins over the mode. | +| Cosmos / Redis / file / custom provider | **Leave alone.** The user chose where their conversation lives, and durable still supplies execution durability. Core injects nothing when one of these is present, so there is no slot to claim. | +| Service-managed history | **Inject a provider anyway.** The service owning the conversation is a property of each run, not of the registration, and a run passing `store=False` would otherwise be answered by a provider core injects and retention cannot see. The provider yields no history on runs the service does own. | +| Agent without the core context pipeline | **Leave alone.** Falls back to replaying persisted history. | + +Preserving `source_id` is the load-bearing detail. `CompactionProvider` locates history through +`history_source_id` (default `"in_memory"`), so a provider swapped in under the same id is invisible +to the rest of the configuration. + +Only the first load-enabled provider is considered. Core permits several, for example a primary store +plus a store-only audit provider, and the others are left exactly as configured. That is deliberate, +since substituting more than one would give two providers the same `source_id`, but it does mean an +audit or evaluation provider keeps whatever storage the user gave it and is not made durable. + +**Substitution changes where history is kept, and does not move what is already there.** Replacing an +`InMemoryHistoryProvider` hands ownership of the conversation to durable entity state from that point +on. Anything the caller had already accumulated in that provider stays where it is and is not copied +across, so the durable conversation begins empty. In practice this is invisible, because an in-memory +provider's contents do not survive the process that registered the agent, and registration happens +before any turn is taken. It would be visible to a caller who populated a provider in-process and then +registered the same instance with a worker, which is worth knowing but is not a supported pattern. No +migration path is offered for it. + +### Entity Context Ownership + +1. **Who supplies conversation context?** If the agent exposes core's context-provider pipeline, + the providers do, so the entity passes a session and delivers **only the new messages**. This + holds whether history lives in durable state, an external store, or the model service. +2. **Who bounds entity state?** Retention does, for every configuration, because the entity records + the exchange even when another provider owns model context. What it records is not always the + content: when another provider owns the conversation, the entity keeps the envelope and forgets + the request content, since that provider's own policy is what bounds the conversation itself. + +The entity therefore replays its own persisted history in exactly one case, an agent that does not +expose the context pipeline. Passing a session re-engages external providers and core's in-run +filter. It does not let core rewrite an external store (gap 1). The session id is derived from the +full entity identity, name plus key, so workflow nodes cannot share an external-provider key. + +### What survives a worker failure, and what does not + +Durability here is **per operation**, not per step within one. An entity operation records the +request, invokes the agent, records the response and persists once. State is written at operation +boundaries, so a worker lost mid-turn loses that turn's work and the operation is retried from its +start. Provider state and the conversation are consistent afterwards because neither was written. + +What that does not give is exactly-once execution of the side effects inside a turn. A tool call +that has already run, or a model call already billed, will run again on retry. This is the same +guarantee an activity gives in Durable Task and it is not weakened here, but it is worth stating +because a reader could reasonably assume that "durable" means checkpointing between tool calls. It +does not, and an agent whose tools are not idempotent should say so through the usual mechanisms +rather than expecting the entity to protect it. + +The one asymmetry is a service that stores conversations. If the model service accepted the turn +before the worker died, the service has a turn the entity did not record, and the retry adds another +one. The entity cannot see that, which is a further reason a conversation id refused by the service +is retried in place rather than worked around. + +### A conversation id the service refuses + +A service that stores conversations can hand back an id it will not accept on the next turn. +Measured against Azure OpenAI, a streamed response reports its id in the completion event before +that response is readable: around half of streamed turns were refused this way when measured, +against none of the non-streamed ones. The id is genuine and was captured correctly, it simply +resolves a moment later. Azure has since treated this as a service defect, and re-measuring found +the chaining path fixed while `responses.retrieve` still lags. + +The entity therefore re-sends the identical request a few times. That recovers the case above, +costs about a second, and requires nothing to be stored. + +An id that has **genuinely expired** produces the same error and cannot be recovered that way, so +those turns fail, as they do in core. The alternative would be to resend our own transcript, which +only works if the entity keeps a full second copy of every conversation the service is already +holding, on every turn, against the chance of needing it. Measured over eight turns that roughly +doubled what a service-backed agent stored. Paying that continuously to insure a rare case is the +wrong trade, so it is not made. If the case proves to matter, it returns as an explicit opt-in +rather than a silent cost. + +### The session is persisted, not just its conversation id + +Providers use session state for data that must survive turns, including pending approvals. Because +the entity creates a session per operation, it persists the serialized session rather than selecting +fields from it. "Serialized session" here means what `AgentSession.to_dict()` produces, which is a +lightweight container: identifiers plus a per-provider state bag. It is not the conversation, and +the exact shape belongs to the hosting runtime rather than to this contract. Two details prevent +duplication and type loss: + +- The service-issued conversation id needs no bespoke field of its own - it is already part of + `AgentSession.to_dict()`. +- The durable history provider's own slice is **excluded** before persisting. It is derived from + `conversationHistory`, so storing it would duplicate the transcript. + +Restore applies the stored state onto a session created by the agent's own `create_session()`, so +the agent's session type is preserved. Core's state-type registry is process-local, so the entity +pre-registers serializable types already loaded in the process before restore. Pydantic state remains +a core gap because broad subclass discovery would be collision-prone. + +### Service-managed conversations + +When the model service stores the conversation, it identifies the thread with an id. The entity +creates a fresh session per operation, so that id is **persisted in durable state and restored on +the next turn** as part of the serialized session. Without it, every turn would start a new thread. + +Whether the service owns history is decided with **core's precedence, not the client class alone**. +An explicit `store` in the agent's options wins, and only when it is unset does the client's +`STORES_BY_DEFAULT` apply. This matters because clients that store by default (such as the Responses +API) are routinely put back into client-side mode with `store=False`. Consulting only +`STORES_BY_DEFAULT` would leave such an agent with a plain in-memory provider that the durable +runtime never persists, silently losing the conversation between turns. + +Core resolves this rule inside `Agent._run` and does not expose the result, so this layer +**re-derives it** and can drift if core changes. *Upstream fix:* expose the resolved decision. The +integration sample covers `store=False` against a store-by-default client. + +### Retention is a deployment policy, not agent configuration + +Compaction annotates, it does not delete. Deletion is configured at **registration** (an app-level +default with a per-agent override) rather than on the agent, so the agent definition stays portable: +the same agent runs in-memory where retention has no meaning. `auto` applies no context policy, but +deletion necessarily shortens future available history. Only `follow_compaction` treats a compaction +exclusion as permission to delete. Under `auto`, the storage budget alone chooses what is removed. + +## Out of Scope: Entity Lifetime + +Idle TTL and cleanup bound how many abandoned entities remain. They do not bound an actively used +entity because each interaction extends its lifetime. Cross-language TTL parity is a separate +decision. + +## More Information + +- Builds on [ADR-0019](https://github.com/microsoft/agent-framework/blob/main/docs/decisions/0019-python-context-compaction-strategy.md) (context compaction strategy), + which defines the in-run / pre-write / on-existing-storage compaction points and the atomic-group + constraint. +- Core reference mechanisms reused: `CompactionProvider` (in-run filter), `InMemoryChatHistoryProvider` + + `IChatReducer` (store reducer), `strategy.AsChatReducer()` bridge, and the existing external + `ChatHistoryProvider` implementations (`CosmosChatHistoryProvider`, `ValkeyChatHistoryProvider`). +- Relevant durable code: `AgentEntity` and `DurableAgentState` (durable agents), + `DurableExecutorDispatcher.ExecuteAgentAsync` (durable workflow agent execution), and + `AgentExecutor` (`context_mode` / `context_filter`, `full_conversation`). From e2a173b0993ac84ce47e64577ab4ebb4f2ec00cb Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Fri, 4 Sep 2026 14:35:04 -0500 Subject: [PATCH 02/13] docs: add turn-flow and persisted-state diagrams to ADR 0032 --- .../0032-durable-thread-compaction.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index 14dbf08..ae42442 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -157,6 +157,43 @@ combined with workflow context projection (Option 4). The two solve different su | **L3, workflow context** | Existing `AgentExecutor.context_mode` / `context_filter` projection | Controls `full_conversation` passed between executors. This is not a core compaction hook. | | **Capacity fallback** | Durable retention | Bounds entity state independently of whether compaction is configured. | +The surfaces act at different points in a single turn. + +```mermaid +flowchart TB + subgraph WF["Durable workflow orchestrator, re-executed every episode"] + CM["L3: context_mode / context_filter
full, last_agent, custom"] + end + + subgraph ENT["AgentEntity, one operation and one state write"] + DUP{"correlation id
already answered?"} + RECORDED["return the recorded response"] + REQ["record the request
content dropped when another store owns it"] + OWN["resolve ownership for this run
store option, else STORES_BY_DEFAULT"] + SESS["create the session,
restore last turn's provider state"] + RESP["record the response"] + RET["L2: prune what compaction excluded
Capacity: evict under pressure"] + end + + subgraph CORE["Inner agent, core pipeline unchanged"] + HP["DurableHistoryProvider
yields nothing when the service owns the run"] + CP["L1: CompactionProvider
projects what the model reads"] + MODEL(["model call"]) + end + + STATE[("durable entity state")] + + CM -->|"RunRequest.context_messages"| DUP + DUP -->|"yes"| RECORDED + DUP -->|"no"| REQ --> OWN --> SESS + SESS -->|"only the new messages"| HP + HP --> CP --> MODEL --> RESP --> RET --> STATE + STATE -.->|"next turn"| HP +``` + +L3 decides what crosses between workflow nodes, L1 decides what the model reads, and retention +decides what survives in storage. Of the three, only retention deletes. + This gives agent-level **configuration parity**, not byte-for-byte parity in every workflow cycle. Durable workflow nodes intentionally deduplicate repeated upstream context before persisting it. The L3 section explains the measured difference. @@ -213,6 +250,28 @@ before core can inject one of its own whose state would be persisted with the en to retention. The provider yields no history on runs the service does own, so the model is never sent a transcript the service is already carrying. +### What the entity persists + +Retention, workflow deduplication and session continuity all read and write the same entity state, +so it is worth seeing its shape before the sections that manipulate it. + +```mermaid +flowchart LR + D["DurableAgentState.data"] + D --> CH["conversationHistory
agentRequest, agentResponse,
agentErrorResponse, compaction"] + D --> SE["session
provider state bag,
service conversation id"] + D --> IP["ingestedPositions
highest position taken
from each workflow executor"] + D --> TR["truncation
evictedMessageCount,
firstEvictedAt, lastEvictedAt"] +``` + +The conversation is the only part retention deletes from, and the other three fields sit outside it +for that reason. `ingestedPositions` survives eviction deliberately, because a watermark stored +among the messages would be removed with them, and a repeating workflow node would then re-ingest +exactly what retention had just deleted. `session` excludes the durable provider's own history +slice, since `conversationHistory` is the record of truth and carrying both would store the +conversation twice. `truncation` exists because deletion has to be discoverable afterwards, and its +absence is itself meaningful, since it says nothing has been dropped. + ### Retention | Mode | Behavior | From 86a81004f43e512cbbb06aa036ff7ed6706d610d Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Fri, 4 Sep 2026 14:46:44 -0500 Subject: [PATCH 03/13] docs: compact the turn-flow diagram --- .../0032-durable-thread-compaction.md | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index ae42442..af251be 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -161,33 +161,28 @@ The surfaces act at different points in a single turn. ```mermaid flowchart TB - subgraph WF["Durable workflow orchestrator, re-executed every episode"] - CM["L3: context_mode / context_filter
full, last_agent, custom"] - end + CM["Workflow orchestrator, re-executed every episode
L3: context_mode / context_filter"] subgraph ENT["AgentEntity, one operation and one state write"] - DUP{"correlation id
already answered?"} - RECORDED["return the recorded response"] - REQ["record the request
content dropped when another store owns it"] - OWN["resolve ownership for this run
store option, else STORES_BY_DEFAULT"] - SESS["create the session,
restore last turn's provider state"] - RESP["record the response"] - RET["L2: prune what compaction excluded
Capacity: evict under pressure"] + DUP{"already answered?"} + DONE["return the recorded response"] + REC["record the request, resolve ownership for this run"] + RET["record the response
L2: prune what compaction excluded, then evict under pressure"] end subgraph CORE["Inner agent, core pipeline unchanged"] - HP["DurableHistoryProvider
yields nothing when the service owns the run"] - CP["L1: CompactionProvider
projects what the model reads"] + HP["DurableHistoryProvider, silent when the service owns the run"] + CP["L1: CompactionProvider"] MODEL(["model call"]) end STATE[("durable entity state")] - CM -->|"RunRequest.context_messages"| DUP - DUP -->|"yes"| RECORDED - DUP -->|"no"| REQ --> OWN --> SESS - SESS -->|"only the new messages"| HP - HP --> CP --> MODEL --> RESP --> RET --> STATE + CM -->|"context_messages"| DUP + DUP -->|"yes"| DONE + DUP -->|"no"| REC + REC -->|"session, plus only the new messages"| HP + HP --> CP --> MODEL --> RET --> STATE STATE -.->|"next turn"| HP ``` From 1b71df960f94da2d88d6e411749884eed9dd595e Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Fri, 4 Sep 2026 14:51:29 -0500 Subject: [PATCH 04/13] docs: add provider ownership, workflow path and registration diagrams --- .../0032-durable-thread-compaction.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index af251be..c1935ba 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -210,6 +210,30 @@ stating plainly because it decides which copy of a conversation is authoritative | The model service | The service's own retention | The exchange, not the content | | No context pipeline at all | Durable retention | Everything, since nothing else holds it | +The same four cases as a path. The branch decides where the conversation lives, and that in turn +decides what the entity keeps. + +```mermaid +flowchart TB + AGENT["Inner agent with core's context pipeline"] + NOPIPE["Agent without the context pipeline"] + SLOT{"which provider holds
the conversation?"} + + AGENT --> SLOT + SLOT -->|"durable, injected or swapped in"| ES["durable entity state
bounded by retention"] + SLOT -->|"Redis, Cosmos, file, custom,
left exactly as configured"| EXT["the customer's store
bounded by their own policy"] + SLOT -->|"durable attached but silent,
the service owns this run"| SVC["the model service
bounded by the service"] + NOPIPE -->|"entity replays its own history"| ES + + ES --> KEEPALL["entity keeps the content,
because nothing else holds it"] + EXT --> KEEPENV["entity keeps the envelope,
correlation id, timestamps and message ids,
and forgets the request content"] + SVC --> KEEPENV +``` + +Only the leftmost branch makes the entity the owner of the conversation. In the other two the +entity is a record of the exchange rather than a second copy of the content. Responses sit outside +this entirely and are kept in every branch, for the reason below. + The entity records every exchange in every configuration, because correlation ids and response delivery are its job and nothing else can do them. It does not have to be a second copy of the conversation, and being one would put the customer's content under two different retention, @@ -497,6 +521,35 @@ service-managed conversations. ## L3 Realization: Workflow Context Parity +A workflow adds one hop in front of the agent path and changes nothing behind it. + +```mermaid +flowchart TB + subgraph ORCH["Durable workflow orchestrator, re-executed every episode"] + FC["full_conversation"] + PROJ["L3: context_mode / context_filter
full, last_agent, custom"] + FC --> PROJ + end + + DEDUP["drop positions this node already ingested,
always keep the newest message as input"] + + subgraph NODE["Agent node, the ordinary durable agent path"] + ENTITY["AgentEntity, one per node
its own conversationHistory and ingestedPositions"] + INNER["inner agent
L1, L2 and retention all inherited"] + ENTITY --> INNER + end + + PROJ -->|"context_messages, stamped wf executor position"| DEDUP + DEDUP --> ENTITY + INNER -->|"response"| FC +``` + +Because a node runs the same `DurableAIAgent` to `AgentEntity` to inner agent path as a standalone +durable agent, everything in the first diagram still applies inside it. Only the projection and the +deduplication are workflow-specific. Each node keeps its own history, keyed by workflow instance and +executor, so nodes do not share a conversation and their memory survives restarts independently of +the workflow envelope. + In-process workflows give a downstream `AgentExecutor` the upstream conversation through `AgentExecutorResponse.full_conversation`, governed by `context_mode` (`full` | `last_agent` | `custom` + `context_filter`). The durable orchestrator previously flattened that to the **last @@ -598,6 +651,25 @@ so the caller's instance remains unchanged. | Service-managed history | **Inject a provider anyway.** The service owning the conversation is a property of each run, not of the registration, and a run passing `store=False` would otherwise be answered by a provider core injects and retention cannot see. The provider yields no history on runs the service does own. | | Agent without the core context pipeline | **Leave alone.** Falls back to replaying persisted history. | +What that looks like as a single decision, taken once at registration. + +```mermaid +flowchart TB + Q{"what did the agent
already have?"} + Q -->|"nothing"| INJ["inject the durable provider, under the
source_id core's own injection would have used"] + Q -->|"InMemoryHistoryProvider"| REP["replace it, preserving
source_id and skip_excluded"] + Q -->|"DurableHistoryProvider, wired by hand"| KEEP["keep it, rebuilding with the mode's pruning
only when prune_excluded was left unset"] + Q -->|"Redis, Cosmos, file, custom"| LEAVE["leave it alone, core injects nothing
when one is present, so there is no slot to claim"] + Q -->|"no context pipeline at all"| NONE["leave the agent alone,
the entity replays its own history instead"] + + INJ --> SRC["an attached CompactionProvider keeps working,
because it resolves history by source_id"] + REP --> SRC +``` + +Substitution is a registration-time decision, but **who serves history is a per-run one**. A +service-managed agent still gets a provider attached here, and the previous diagram shows why that +provider then stays silent on the runs the service actually owns. + Preserving `source_id` is the load-bearing detail. `CompactionProvider` locates history through `history_source_id` (default `"in_memory"`), so a provider swapped in under the same id is invisible to the rest of the configuration. From 5c7c96ac06a438d29da20185e453d4cc47d52494 Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Fri, 4 Sep 2026 14:52:46 -0500 Subject: [PATCH 05/13] docs: name the branch instead of relying on its rendered position --- docs/decisions/0032-durable-thread-compaction.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index c1935ba..d34bf54 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -230,9 +230,9 @@ flowchart TB SVC --> KEEPENV ``` -Only the leftmost branch makes the entity the owner of the conversation. In the other two the -entity is a record of the exchange rather than a second copy of the content. Responses sit outside -this entirely and are kept in every branch, for the reason below. +Only the durable-entity-state branch makes the entity the owner of the conversation. In the other +two the entity is a record of the exchange rather than a second copy of the content. Responses sit +outside this entirely and are kept in every branch, for the reason below. The entity records every exchange in every configuration, because correlation ids and response delivery are its job and nothing else can do them. It does not have to be a second copy of the From 3acc50759b5dd1c755b6898fec723124b1e9ccb9 Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Fri, 4 Sep 2026 19:58:31 -0500 Subject: [PATCH 06/13] docs: revise ADR 0032 after design review Separate compaction pruning from pressure eviction, isolate response delivery and duplicate suppression, bound workflow transport with per-target deltas, and define provider and state-evolution follow-ups. --- .../0032-durable-thread-compaction.md | 526 ++++++++++++------ 1 file changed, 354 insertions(+), 172 deletions(-) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index d34bf54..b1fccb8 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -30,7 +30,7 @@ It helps to separate **three distinct pressures**, because they have different o | --- | --- | --- | --- | | **Context window**, the model's max input per call | the model | **Yes**, identical in core and durable | Compaction (in-run filter) | | **Token cost / latency**, resending history each turn | tokens billed / round-trip | **Yes**, same mechanism | Compaction (in-run filter) | -| **Storage capacity**, the cumulative persisted state | backend state-size limit | **No**, durable-only | Backend offload and durable retention | +| **Storage capacity**, the cumulative persisted state | backend state-size limit | **No**, durable-only | Backend offload and explicit durable retention | The first two are per-operation and identical in both runtimes. The third is cumulative. `ConversationHistory` is one blob appended to every turn and re-persisted whole, so it is bounded by @@ -62,9 +62,9 @@ Core MAF already has a compaction system ([ADR-0019](https://github.com/microsof it persists the result against a hard backend limit rather than holding it in process memory. `retention="follow_compaction"` is the answer for anyone who wants compaction without paying for it in storage: the same six turns end at 2,296 bytes, below the 3,422 they would have reached - with no compaction at all. Note that `auto`, the default, does **not** bound this growth. It - reacts to pressure rather than to compaction, so it behaves identically to `keep_all` until the - budget is approached. + with no compaction at all. With the default `retention="keep_all"`, compaction exclusions remain + non-lossy. A separate `max_state_bytes` budget can evict under pressure whether or not compaction + is configured. 2. **Store reducer.** **Lossily** rewrites the stored conversation, applying the same strategies at the store instead of at the model call. Unlike the in-run filter, this hook is tied to a specific storage mechanism in both languages. .NET exposes an `IChatReducer` on `InMemoryChatHistoryProvider` @@ -92,12 +92,11 @@ agent configuration bounds model input and the persisted store can be bounded se parallel durable-only API. - **Reuse existing core hooks.** Do not reinvent triggers, strategies or grouping. Reuse the in-run filter and the store reducer. -- **Separate storage capacity from context management.** Bound the model input with compaction - (parity with core), raise backend capacity where possible, and use observable deletion only as a - fallback. -- **Deleting is a last resort, and never silent.** Entity state is a state bag, not an immutable - system of record, so deleting from it is legitimate. But deletion should happen only when capacity - demands it, should remove no more than capacity demands, and should always be observable. +- **Separate storage capacity from context management.** Bound model input with compaction (parity + with core), raise backend capacity where possible, and configure storage deletion independently. +- **Deletion is explicit and observable.** Entity state is a state bag, not an immutable system of + record, so deleting from it is legitimate. The user must opt in either by following their own + compaction exclusions or by setting a pressure budget. Every deletion remains observable. - **Determinism and idempotency.** Durable entity operations can be retried, so a lossy reducer (especially LLM summarization) must not corrupt or diverge persisted state across retries. - **Message-list correctness.** Preserve atomic groups (assistant tool-call plus tool-result, and @@ -121,11 +120,12 @@ agent configuration bounds model input and the persisted store can be bounded se - **Option 3, on-storage maintenance compaction (deferred).** Compact persisted history from a separate entity operation. This may suit expensive summarization but does not prevent in-turn growth. -- **Option 4, workflow context projection (chosen).** Honor `AgentExecutor.context_mode` and - `context_filter` for the `full_conversation` chained between executors. +- **Option 4, workflow context projection and delta transport (chosen).** Honor + `AgentExecutor.context_mode` and `context_filter`, then send each target only the unseen suffix of + that projection rather than serializing the whole `full_conversation` on every visit. - **Option 5, auto-derive a durable store reducer (rejected as default).** Derive a lossy reducer - from a configured in-run strategy. The explicit equivalent is `follow_compaction`. The default - must also protect agents with no compaction strategy. + from a configured in-run strategy. The explicit equivalent is `follow_compaction`. Agents with + no compaction strategy can opt into independent pressure eviction with `max_state_bytes`. - **Option 6, durable store as a `ChatHistoryProvider` (chosen).** Back the durable entity's persisted conversation with a core `ChatHistoryProvider` implementation, so both core hooks apply on the durable runtime from the user's unchanged configuration. The in-run filter runs in the @@ -140,22 +140,23 @@ agent configuration bounds model input and the persisted store can be bounded se Deliberately **not** counted as part of the chosen design, because it is not available everywhere. It adds an Azure Blob payload-store dependency, the Azure Storage backend already does the equivalent internally so configuring it there is redundant, and Durable Functions Python cannot - opt in at all today (gap 6). Treat it as an optimization a specific deployment may enable, detected - rather than assumed: nothing in this design may depend on it being present, and `max_state_bytes` - stays at the unoffloaded limit unless a deployment raises it deliberately. Retention is what has to - be correct on every host, and is specified without reference to offload. + opt in at all today (gap 6). Treat it as an optimization a specific deployment may enable, + detected rather than assumed: nothing in this design may depend on it being present. A deployment + can set an explicit byte budget, or use `max_state_bytes="backend_limit"` when its host exposes a + hard entity limit. A host that cannot identify such a limit requires an explicit number. ## Decision Outcome Chosen option: **Option 6, express durable conversation storage as a core `ChatHistoryProvider`**, -combined with workflow context projection (Option 4). The two solve different surfaces. +combined with workflow context projection and delta transport (Option 4). The two solve different +surfaces. | Surface | Mechanism | Behavior | | --- | --- | --- | | **L1, agent context** | The user's configured core `CompactionProvider` / `compaction_strategy` | Non-lossy projection of model input. The same agent configuration works durably. | | **L2, eager store pruning** | Core compaction annotations plus `retention="follow_compaction"` | Opt-in deletion of messages the user's strategy excluded. Python only today because .NET is blocked by duplicated compaction state (gap 4). | -| **L3, workflow context** | Existing `AgentExecutor.context_mode` / `context_filter` projection | Controls `full_conversation` passed between executors. This is not a core compaction hook. | -| **Capacity fallback** | Durable retention | Bounds entity state independently of whether compaction is configured. | +| **L3, workflow context** | Existing `AgentExecutor.context_mode` / `context_filter` projection plus per-target delta transport | Controls what crosses between executors without repeatedly sending the same prefix. This is not a core compaction hook. | +| **Capacity safety** | Optional `max_state_bytes` budget | Evicts oldest groups under pressure, independently of whether compaction is configured. | The surfaces act at different points in a single turn. @@ -164,10 +165,12 @@ flowchart TB CM["Workflow orchestrator, re-executed every episode
L3: context_mode / context_filter"] subgraph ENT["AgentEntity, one operation and one state write"] - DUP{"already answered?"} - DONE["return the recorded response"] + DUP{"mailbox payload or
completed tombstone?"} + DONE["return the response or
an already-completed result"] REC["record the request, resolve ownership for this run"] - RET["record the response
L2: prune what compaction excluded, then evict under pressure"] + RESP["record the response in the transcript and mailbox"] + L2["L2, if enabled: prune what compaction excluded"] + CAP["Capacity, if enabled: evict under pressure"] end subgraph CORE["Inner agent, core pipeline unchanged"] @@ -182,21 +185,22 @@ flowchart TB DUP -->|"yes"| DONE DUP -->|"no"| REC REC -->|"session, plus only the new messages"| HP - HP --> CP --> MODEL --> RET --> STATE + HP --> CP --> MODEL --> RESP --> L2 --> CAP --> STATE STATE -.->|"next turn"| HP ``` -L3 decides what crosses between workflow nodes, L1 decides what the model reads, and retention -decides what survives in storage. Of the three, only retention deletes. + L3 decides what crosses between workflow nodes, L1 decides what the model reads, and the two + storage controls decide what survives. Only the storage controls delete, and each is opt-in. This gives agent-level **configuration parity**, not byte-for-byte parity in every workflow cycle. Durable workflow nodes intentionally deduplicate repeated upstream context before persisting it. The L3 section explains the measured difference. -Capacity is handled in this order: choose a workflow context mode that does not carry the whole -conversation, raise the ceiling non-lossily where blob offload is available, honor an explicit -`follow_compaction` choice, then evict under pressure. An exclusion normally means only "do not send -this to the model". It means "delete this" only under `follow_compaction`. +Capacity is handled in this order: project workflow context according to its semantics, send only +the unseen suffix to each target, raise the ceiling non-lossily where blob offload is available, +honor an explicit `follow_compaction` choice, then apply pressure eviction only when a byte budget +was configured. An exclusion normally means only "do not send this to the model". It means "delete +this" only under `follow_compaction`. ### Who bounds what @@ -206,9 +210,9 @@ stating plainly because it decides which copy of a conversation is authoritative | Where the conversation lives | What bounds it | What the entity keeps | | --- | --- | --- | | The customer's own store (Redis, Cosmos, file) | Their store's own policy, for example Redis `max_messages` or a Cosmos container TTL | The exchange, not the content | -| Durable entity state | Durable retention, described below | Everything, since nothing else holds it | +| Durable entity state | Explicit `follow_compaction`, an optional pressure budget, or ultimately the backend limit | Everything, since nothing else holds it | | The model service | The service's own retention | The exchange, not the content | -| No context pipeline at all | Durable retention | Everything, since nothing else holds it | +| No context pipeline at all | Explicit `follow_compaction`, an optional pressure budget, or ultimately the backend limit | Everything, since nothing else holds it | The same four cases as a path. The branch decides where the conversation lives, and that in turn decides what the entity keeps. @@ -220,7 +224,7 @@ flowchart TB SLOT{"which provider holds
the conversation?"} AGENT --> SLOT - SLOT -->|"durable, injected or swapped in"| ES["durable entity state
bounded by retention"] + SLOT -->|"durable, injected or swapped in"| ES["durable entity state
explicit retention, pressure budget, or backend limit"] SLOT -->|"Redis, Cosmos, file, custom,
left exactly as configured"| EXT["the customer's store
bounded by their own policy"] SLOT -->|"durable attached but silent,
the service owns this run"| SVC["the model service
bounded by the service"] NOPIPE -->|"entity replays its own history"| ES @@ -241,20 +245,6 @@ residency and deletion policies when they deliberately chose one store for it. S provider owns the conversation, the entity keeps the envelope, the correlation id, the timestamps and the message ids, and forgets the content. -**Responses are the exception, deliberately.** A caller collects its answer by polling this entity -for a correlation id, so the entity is the only thing that can produce it. Response content is -therefore retained regardless of who owns the conversation. - -That retention is not merely a cost. An entity signal is one-way, so for the client and HTTP paths -the recorded response *is* the return value, and persisting it is what lets a caller that crashed -between the agent finishing and the poll landing still collect a result whose model tokens and tool -side effects have already been paid for. It also makes the request answered **once**: signals are -delivered at least once and every path mints a fresh correlation id per request, so a repeated id is -a duplicate delivery rather than a caller asking again. The entity returns the recorded answer -instead of running the agent a second time. Before that check existed, a duplicate spent another -model call and produced a second, different answer that nothing could collect, since pollers take -the first match for a correlation id. - Orchestrations reach the entity through `call_entity` instead, which returns the value directly. The same bytes then exist in two places, but they are not two copies of one thing: the orchestrator records a **task result**, which is what makes its replay deterministic, while the entity records @@ -262,12 +252,48 @@ records a **task result**, which is what makes its replay deterministic, while t removable, and the overlap is two systems recording the same event for different reasons rather than a defect in either. -**Ownership is resolved per run, not per registration.** `store` is an ordinary run option, so an -agent registered against a service-storing client can still be asked to keep a single turn -client-side. A durable history provider is therefore attached in that case too, claiming the slot -before core can inject one of its own whose state would be persisted with the entity and invisible -to retention. The provider yields no history on runs the service does own, so the model is never -sent a transcript the service is already carrying. +**Ownership is resolved per run, as it is in core.** Core gives an explicit `store` in the effective +run options precedence over the client's `STORES_BY_DEFAULT`, so an agent registered against a +service-storing client can still be asked to keep one turn client-side. Durable mirrors that rule +rather than pinning an owner for the session and rejecting a core-supported run option. + +A durable history provider is attached at registration even for a service-storing client. It +claims the history slot before core can inject an `InMemoryHistoryProvider` on a later `store=False` +run. Persisting that injected provider with the session grew state by 321 bytes per turn in the +prototype and put the transcript outside durable retention. The durable provider instead yields no +history on runs the service owns, so the model is never sent a transcript the service already +carries. + +Changing `store` does not migrate history between owners. A client-side turn after service-owned +turns therefore sees a gap, which is the same behavior as core. The entity still keeps contentless +envelopes in `conversationHistory` for correlation and audit. Their message `contents` are empty, +and the history provider omits those messages from model context rather than rebuilding blank turns. +An explicit migrate or fork operation would be a core capability, not a durable-specific +reinterpretation of `store`. + +### Response delivery and duplicate suppression + +Model context, response delivery and duplicate suppression have different lifecycles. They must not +all depend on one entry remaining in `conversationHistory`. + +For client and HTTP paths, an entity signal is one-way and the caller polls by correlation id. The +response is therefore a delivery obligation. A `responseMailbox` retains each completed response +payload, or an offloaded reference to it, under that correlation id until a configured delivery +expiry. The current polling surface only reads entity state and cannot acknowledge receipt, so the +first implementation uses bounded expiry. A future acknowledgement operation can shorten it. + +When delivery expiry passes, the mailbox obligation ends and its payload or reference can be +removed. `completedCorrelations` retains a lightweight tombstone until the entity itself is deleted. +A repeated correlation id then produces an already-completed result instead of another model call +and another set of tool side effects. Pressure eviction never removes live mailbox entries or +tombstones. They can therefore become part of the non-evictable floor and cause a capacity error +rather than permit duplicate execution. Automatic entity cleanup is tracked separately under entity +lifetime. + +The response can still participate in model context while its transcript entry survives. The +mailbox controls whether the caller can collect the result, while transcript retention controls +whether a later model call sees it. An implementation may share the underlying payload while both +references are live, but deletion decisions remain independent. ### What the entity persists @@ -277,48 +303,87 @@ so it is worth seeing its shape before the sections that manipulate it. ```mermaid flowchart LR D["DurableAgentState.data"] - D --> CH["conversationHistory
agentRequest, agentResponse,
agentErrorResponse, compaction"] + D --> CH["conversationHistory
model transcript and exchange record"] + D --> MB["responseMailbox
response delivery"] + D --> CC["completedCorrelations
duplicate suppression"] D --> SE["session
provider state bag,
service conversation id"] - D --> IP["ingestedPositions
highest position taken
from each workflow executor"] + D --> IP["ingestedPositions
workflow redelivery safety net"] D --> TR["truncation
evictedMessageCount,
firstEvictedAt, lastEvictedAt"] ``` -The conversation is the only part retention deletes from, and the other three fields sit outside it -for that reason. `ingestedPositions` survives eviction deliberately, because a watermark stored -among the messages would be removed with them, and a repeating workflow node would then re-ingest -exactly what retention had just deleted. `session` excludes the durable provider's own history -slice, since `conversationHistory` is the record of truth and carrying both would store the -conversation twice. `truncation` exists because deletion has to be discoverable afterwards, and its -absence is itself meaningful, since it says nothing has been dropped. +The fields separate six lifecycles that one conversation array cannot safely own. Transcript +retention, response delivery, duplicate suppression, provider state, workflow redelivery and the +audit evidence of truncation can now expire or fail independently. Pressure eviction deletes only +from `conversationHistory`. Mailbox payloads follow their delivery expiry, while correlation +tombstones are non-evictable proof that an operation already completed. + +`ingestedPositions` survives eviction deliberately, because a watermark stored among the messages +would be removed with them and a redelivered workflow delta would then be accepted twice. `session` +excludes the durable provider's own history slice, since `conversationHistory` is the record of +truth and carrying both would store the conversation twice. `truncation` exists because deletion +has to be discoverable afterwards, and its absence says nothing has been dropped. + +Each entity holds one durable agent session. A new standalone session gets a new entity key, and a +workflow agent node is keyed by workflow instance plus executor. The 1 MB DTS limit and any +`max_state_bytes` budget therefore apply to one session, not to every conversation for an agent. +Old sessions occupy separate entities and do not reduce the budget of later sessions. How long those +abandoned entities remain is the separate entity-lifetime concern described below. ### Retention -| Mode | Behavior | +Deletion has two independent controls. `retention` says whether a compaction exclusion is also +permission to delete. `max_state_bytes` says whether storage pressure may delete messages that the +user did not exclude. Neither control turns the other on. + +| Control | Value | Behavior | +| --- | --- | --- | +| `retention` | `keep_all` **(default)** | Preserve messages that compaction excluded from model input. | +| `retention` | `follow_compaction` | Delete excluded messages after every turn. With no compaction configured, this has nothing to delete. | +| `max_state_bytes` | `None` **(default)** | Do not evict under pressure. A hard backend limit can still reject a write. | +| `max_state_bytes` | `"backend_limit"` | Use the hard entity-payload limit known to the host, 1,048,576 bytes for direct DTS. Registration fails if the host cannot identify one. | +| `max_state_bytes` | positive integer | Use that explicit serialized-state budget. | + +Together they make all four policies expressible. + +| | No pressure budget | Pressure budget set | +| --- | --- | --- | +| `keep_all` | Never delete. | Preserve exclusions, but evict oldest groups under pressure. | +| `follow_compaction` | Delete only what the user's compaction strategy excluded. | Delete exclusions eagerly, then evict oldest groups if the remainder still crosses the high watermark. | + +**Why deletion is opt-in.** Core follows the same rule for every comparable bound. + +| Core mechanism | Default | | --- | --- | -| `keep_all` | Never delete. The entity may reach the backend limit and fail. | -| `auto` **(default)** | Delete only under storage pressure, targeting the low watermark. | -| `follow_compaction` | Delete whatever compaction excluded every turn, then use the same pressure eviction as `auto` if the remaining state is still too large. | - -**Why a deleting default.** `auto` means the runtime may remove customer conversation content -without being asked, which deserves an argument rather than an assumption. The alternative is -`keep_all`, and its failure mode is worse: the entity reaches the backend limit and then cannot be -written to at all, so the agent stops answering and the conversation is unrecoverable rather than -merely shortened. Since eviction is oldest-first and stops at the low watermark, `auto` trades the -oldest part of a conversation for the session continuing to work. That is the right default for a -runtime whose purpose is durability, but only because it is bounded, ordered, and recorded: the -newest exchange and any response a caller may still be reading are never evicted, and the -`truncation` record means the loss is discoverable afterwards. `keep_all` remains available for -callers who would rather fail than forget. +| `InMemoryHistoryProvider` | Unbounded | +| `RedisHistoryProvider.max_messages` | `None`, unbounded | +| `compaction_strategy` | `None` | +| Context-window compaction | The user must supply `max_context_window_tokens` | + +Durable storage should not silently adopt a more destructive default. Without a pressure budget, a +write that exceeds the backend limit fails while the last successfully persisted state remains +available. The operator can then raise the limit, enable a budget, or choose `follow_compaction`. +Failure is visible and recoverable; deletion is irreversible. **How pressure eviction works.** After the turn is recorded and before the state is persisted, the -entity measures its serialized state. `auto` uses only this path. `follow_compaction` uses it after -eager pruning. Below the high watermark, nothing happens. Above it, the entity targets the low -watermark using detached message copies with existing exclusions cleared and +entity measures its serialized state. Pressure eviction runs only when `max_state_bytes` is set. +`high_watermark` and `low_watermark` default to `0.85` and `0.70`. They are configurable and must +satisfy `0 < low_watermark < high_watermark <= 1`. Below the high watermark, nothing happens. Above +it, the entity targets the low watermark using detached message copies with exclusions cleared and `TokenBudgetComposedStrategy(strategies=[])`. Clearing exclusions makes the budget reflect what is stored, while the empty strategy list bypasses the user's context policy and uses core's -deterministic oldest-group fallback. Atomic tool groups and the newest exchange are protected, as -are responses recent enough that their caller may still be polling for them. The entity remeasures -after each pass. +deterministic oldest-group fallback. Atomic tool groups and the newest exchange are protected. + +Clearing happens only on the detached planning copy and does not erase stored annotations. Under +pressure, an exclusion is not immunity from capacity eviction: all otherwise eligible old groups +compete by age. `keep_all` means exclusion alone never triggers deletion, while a separately enabled +pressure budget may still evict that group. + +The non-evictable floor is calculated before anything is removed. If the floor alone exceeds the +configured limit, the turn fails with a capacity error without deleting old context. If the low +watermark is unreachable, the target is clamped upward and eviction removes only enough to get +below the high watermark where that is possible. If no target below the high watermark is +reachable, the capacity condition is reported without a futile eviction pass. This prevents a +one-token approximation from deleting every evictable message for a target the state cannot reach. **The budget is derived from bytes, not from text.** The constraint is a byte limit but the strategy counts tokens, so the conversion is measured from the messages in hand: the persisted size of the @@ -338,60 +403,78 @@ the first and last eviction times. A log line is evidence to whoever was watchin nobody afterwards, which is no use to a user asking later why an answer lost context. It is a counter rather than a list of what was removed, because such a list would grow without bound in exactly the situation retention exists to resolve. Its absence is meaningful: it says nothing has been dropped. +`truncation` records loss of model context; `completedCorrelations` records completed execution. +Neither can substitute for the other. -`max_state_bytes` defaults to `1_048_576`. High and low watermarks of `0.85` and `0.70` provide -hysteresis and room for estimation error. Measuring a 1 MB prototype state took about 8 ms. +The measured size is the serialized state JSON, not an estimate from message text and not transport +framing added outside the state payload. Measuring a 1 MB prototype state took about 8 ms. -**Why not simply reduce the store by default.** A default-on reducer only helps agents that already -configured compaction, because nothing else marks messages excludable. It would leave every other -configuration exposed while changing behavior for only a subset of users. +**Why not prune exclusions by default.** A default-on reducer only affects agents that configured +compaction, because nothing else marks messages excludable. It would also turn a non-lossy model +projection into irreversible storage deletion without the user choosing that policy. **Why not rely on blob offload alone.** It raises the ceiling roughly tenfold and does not remove it. -It is also unreachable on the Durable Functions Python path today (gap 6). Retention is therefore -the fallback that works on every host. +It is also unreachable on the Durable Functions Python path today (gap 6). Explicit pressure +retention is therefore the portable fallback a deployment can enable on every host. **Service-managed model context** is outside compaction scope, mirroring ADR-0019. When the model -provider owns the conversation, the client holds no history to compact. Entity retention still -applies. See "Service-managed conversations". +provider owns the conversation, the client holds no history to compact. Configured entity-retention +policies still apply to the entity's own record. See "Service-managed conversations". **Why workflows largely come "for free."** Durable workflow agent execution (`DurableExecutorDispatcher.ExecuteAgentAsync`) runs an agent through the same `DurableAIAgent → AgentEntity → inner agent` path as standalone durable agents, so **L1, L2 and -retention are inherited by workflow agent executors**. The workflow's own `full_conversation` between -executors does not pass through the agent, so it needs the separate **L3** hook. +optional pressure retention are inherited by workflow agent executors**. The workflow's own +`full_conversation` between executors does not pass through the agent, so it needs the separate +**L3** hook and delta transport. ### Consequences - **Configuration parity.** Existing agent compaction configuration works durably without changing the agent. Retention does not choose the current model projection. -- **Broad capacity protection.** Because pressure eviction lives in the entity, it covers external - providers, service-managed agents, and agents with no context pipeline. A single oversized newest - exchange can still fail because the current result is never evicted. -- **Proportionate deletion.** Under `auto`, the budget decides how much to remove. The user's context - strategy does not. +- **Independent deletion policies.** Users can follow their own compaction exclusions without + enabling pressure eviction, or enable pressure eviction while preserving those exclusions in the + stored transcript. +- **Opt-in capacity protection.** When a pressure budget is set, it covers external providers, + service-managed agents, and agents with no context pipeline. With no budget, the backend can + reject an oversized write. A non-evictable floor can still produce a capacity error either way. +- **Delivery correctness has its own cost.** Mailbox entries and completed-correlation tombstones + cannot be pressure-evicted. They consume part of the floor so the system fails rather than + silently re-executing a completed request. - **Larger entity change.** The history-provider design must preserve response polling and the entity's conversation record. - **Python-only eager pruning.** .NET would duplicate the transcript if it persisted current compaction state (gap 4), so a .NET implementation could use pressure retention but not L2 yet. - **Core workarounds.** Python must publish and reconcile a working buffer because core binds store-side compaction to session state (gaps 1 and 2). -- **Threshold behavior.** `auto` changes behavior only near the capacity limit. This is less uniform - than always pruning, but it avoids changing unaffected conversations. +- **Threshold behavior.** Pressure eviction changes behavior only near the configured budget. This + is less uniform than always pruning, but it leaves unaffected conversations unchanged. ### Validation -**Done (Python).** Unit tests cover provider substitution, annotation round-trips, synthetic summary -insertion and reconciliation, all retention modes, session persistence, and workflow projection and -deduplication. The retention test drives a real agent through twenty turns against a reduced budget. -`keep_all` is the control proving the same run exceeds it. Scheduler integration covers persisted -annotations and message ids, external-provider session identity, schema conformance, and downstream -workflow context. +**Prototype evidence (Python).** Unit tests cover provider substitution, annotation round-trips, +synthetic summary insertion and reconciliation, the original retention modes, session persistence, +and workflow projection and target-side deduplication. A retention test drives a real agent through +twenty turns against a reduced budget. Scheduler integration covers persisted annotations and +message ids, external-provider session identity, schema conformance, downstream workflow context, +and Redis as the sole owner of an external conversation. + +**Required for the revised implementation.** Not covered by the prototype yet. -**Outstanding.** Not covered yet. +- Response mailbox expiry and completed-correlation tombstones, including a redelivery after the + transcript response has been evicted. +- Independent eager-pruning and pressure controls, the `"backend_limit"` sentinel, configurable + watermarks, and a floor that cannot trigger futile deletion. +- Contentless envelope suppression when a session changes from service-owned to client-owned. +- Per-target workflow delta transport across cycles, fan-out, fan-in and orchestration replay. +- Registration failure for more than one load-enabled history provider. + +**Longer-term validation.** Tracked here until the ADR is approved and follow-up issues are filed. - Retention crossing the real scheduler limit against a live backend, rather than a reduced budget in process. -- The .NET realization and its schema parity (gap 3), and the .NET compaction-state blocker (gap 4). +- Bidirectional Python/.NET state tests, including unknown entry-kind preservation and rollback. +- The .NET realization and its compaction-state blocker (gap 4). - Blob offload (Option 7) against a real scheduler. It remains unreachable through Durable Functions Python 1.x and the 2.x preview (gap 6). - Idempotency of an LLM-based reducer across simulated entity retries. @@ -403,6 +486,12 @@ workflow context. - Reuse core grouping so tool-call/result and reasoning groups remain atomic. - Pressure eviction is deterministic and uses the estimator tokenizer without a model call. Any future LLM reducer must give summaries stable identities and be tested across retries. +- Response delivery and duplicate suppression do not depend on transcript retention. Pressure + eviction cannot remove a live mailbox entry or the only completed-correlation tombstone. +- Registration permits exactly one load-enabled primary history provider. Additional providers are + store-only sinks. +- Workflow projection preserves `context_mode` semantics, while a replay-derived per-target cursor + removes already-sent prefixes from transport. Entity positions remain the redelivery guard. - The durable history provider belongs in `AgentEntity`. Workflow projection belongs at the existing `AgentExecutor.context_mode` / `context_filter` seam. @@ -458,18 +547,27 @@ the provider abstraction, which is what makes it a prerequisite rather than a ti `flush()` alongside the existing append, would let core drive store rewrite through the abstraction instead, make the capability discoverable, and remove the impersonation. - **What must land upstream before cross-language parity is complete**, stated so it can be checked - rather than argued: - - 1. Store rewrite expressed on the provider abstraction, so `after_strategy` reaches any store. - 2. A replace/flush operation alongside append, so summaries and annotations have a path back. - 3. Core exposing its **resolved** service-versus-client history ownership. Durable currently - re-derives it from `store` and `STORES_BY_DEFAULT`, which duplicates a decision core has - already made and will drift the moment core's precedence changes. - 4. .NET reaching the same point, which additionally needs `MessageId` and - `AdditionalProperties` to survive `FromChatMessage`/`ToChatMessage` (gap 3). - - Items 1 to 3 are the same contract work and should be designed together. + **The provider contract this should become**, stated here until follow-up issues are filed after + this ADR is approved: + + 1. Store rewrite expressed on the provider abstraction, so compaction reaches any capable store. + 2. `replace_messages()` / `flush()` with an expected version, so summaries, annotations and + deletions have a concurrency-safe path back. + 3. `clear()` / `delete_session()` so reset and lifecycle behavior belong to the store that owns + the conversation. + 4. Versioned `snapshot_state()` / `restore_state()` so a provider explicitly declares what may + survive a durable turn and how that state migrates. + 5. Core exposing its **resolved** service-versus-client history ownership. Durable currently + re-derives it from `store` and `STORES_BY_DEFAULT`, which duplicates a decision core has + already made and can drift if core changes. + 6. .NET reaching the same point, which additionally needs `MessageId` and + `AdditionalProperties` to survive `FromChatMessage` / `ToChatMessage` (gap 3). + + These are upstream capabilities, not prerequisites for the first Python implementation. The + durable provider's working-buffer reconciliation remains the bounded workaround until the + contract exists. Provider-owned snapshots replace broad session serialization only after a + provider can supply a version and migration policy; wrapping an opaque state bag in a versioned + envelope before then would imply a guarantee no provider has made. 3. **Message-level metadata was not persisted (durable schema).** Python wrote `extension_data` asymmetrically, so annotations disappeared on round-trip. This is fixed. The @@ -519,6 +617,32 @@ Two more core gaps are described where they matter: the process-local state-type session persistence, and the lack of a public resolved history-ownership decision under service-managed conversations. +## Cross-Language State Evolution + +The state schema is shared by Python and .NET, so additive JSON is not automatically a safe minor +version. The current .NET reader registers only the `request` and `response` discriminators and +throws on an unknown `$type`. The previous Python reader also throws, because its fallback still +converts `$type` through an enum that contains only those two values. Writing `errorResponse` or +`compaction` before both readers understand them would therefore break a mixed-version worker and a +rollback to the previous Python package. + +New entry kinds and lifecycle fields use a two-phase rollout. + +1. Ship readers in both runtimes that accept the new fields, preserve unknown optional data, and + round-trip an unknown entry as raw JSON without admitting it into model context. +2. Only after those readers are available may a writer persist `errorResponse`, `compaction`, + `responseMailbox`, `completedCorrelations` or other new state shapes. + +Phase 1 ships as a separate compatibility change before any phase 2 writer. All workers sharing a +task hub must move to that reader floor before a phase 2 package is deployed. A worker cannot +inspect the versions of its peers, so this is a release and deployment gate rather than a runtime +handshake. + +Rollback is supported only to a reader from phase 1 or later. If that staged rollout is not +possible, the writer must use a new major schema version and the runtime must gate the write rather +than relying on the current major-only read check. Bidirectional tests must cover Python-written +state read and rewritten by .NET, the reverse direction, unknown entry preservation, and rollback. + ## L3 Realization: Workflow Context Parity A workflow adds one hop in front of the agent path and changes nothing behind it. @@ -528,27 +652,26 @@ flowchart TB subgraph ORCH["Durable workflow orchestrator, re-executed every episode"] FC["full_conversation"] PROJ["L3: context_mode / context_filter
full, last_agent, custom"] - FC --> PROJ + DELTA["select the unseen suffix for this target
with a replay-derived target, producer cursor"] + FC --> PROJ --> DELTA end - DEDUP["drop positions this node already ingested,
always keep the newest message as input"] - subgraph NODE["Agent node, the ordinary durable agent path"] - ENTITY["AgentEntity, one per node
its own conversationHistory and ingestedPositions"] + GUARD["ingestedPositions
reject a redelivered delta"] + ENTITY["AgentEntity, one per node
its own conversationHistory"] INNER["inner agent
L1, L2 and retention all inherited"] - ENTITY --> INNER + GUARD --> ENTITY --> INNER end - PROJ -->|"context_messages, stamped wf executor position"| DEDUP - DEDUP --> ENTITY + DELTA -->|"only new context_messages,
stamped wf executor position"| GUARD INNER -->|"response"| FC ``` Because a node runs the same `DurableAIAgent` to `AgentEntity` to inner agent path as a standalone durable agent, everything in the first diagram still applies inside it. Only the projection and the -deduplication are workflow-specific. Each node keeps its own history, keyed by workflow instance and -executor, so nodes do not share a conversation and their memory survives restarts independently of -the workflow envelope. +delta transport are workflow-specific. Each node keeps its own history, keyed by workflow instance +and executor, so nodes do not share a conversation and their memory survives restarts independently +of the workflow envelope. In-process workflows give a downstream `AgentExecutor` the upstream conversation through `AgentExecutorResponse.full_conversation`, governed by `context_mode` (`full` | `last_agent` | @@ -599,16 +722,30 @@ takes the first, and the contract above is the price. Revisiting that, along wit private `_context_mode` / `_context_filter` reads with a public accessor, is tracked in [#79](https://github.com/microsoft/agent-framework-durable-extension/issues/79). -Cycles need deduplication because a node receives the accumulated upstream conversation again on -each visit. The orchestrator stamps each forwarded message as `wf_{executor}_{position}`. The entity -stores the highest ingested position per executor and drops older positions, keeping the newest -message as input when everything repeats. Per-executor watermarks are required because fan-out -branches can share a position. +Projection and transport are separate. After applying `context_mode` or `context_filter`, the +orchestrator sends each target only positions it has not sent to that target before. The cursor is +keyed by target and producing executor, because fan-out targets advance independently and fan-in +combines positions from several producers. Messages remain stamped as +`wf_{executor}_{position}`. -### Context mode is the first answer to workflow capacity +Each message is compared only with the cursor for its own `(target, producer)` pair. Fan-in does not +take a minimum or maximum across producers: positions from two branches are independent even when +the numeric indexes happen to match. A cursor at 20 means the next delta for that pair begins after +20; it never requests positions that the target later evicted from its transcript. -The projection is what grows with the conversation, and it is already a choice the workflow author -makes. Measured, serialized, as the conversation lengthens: +The cursor is derived rather than checkpointed. A durable orchestrator re-executes the same message +sequence from the top on every episode, so a local cursor map is reconstructed deterministically +before any recorded task result is reused. The entity still persists its highest ingested position +per producer. That is no longer the primary transport mechanism; it is the safety net that rejects +an at-least-once redelivery of a delta. Pressure retention never removes that position map. If the +entity is already ahead of a replay-derived transport cursor, it drops the repeated positions and +accepts only newer ones; neither side asks for an evicted prefix to be sent again. + +### Projection and delta transport bound different costs + +`context_mode` is a semantic choice about what a target may see. Delta transport is a capacity +mechanism that avoids serializing the same allowed prefix repeatedly. The prototype measured each +complete projection before target-side deduplication as the conversation lengthened: | Turns | `full` (default) | `last_agent` | `custom`, last 4 messages | | ---: | ---: | ---: | ---: | @@ -617,24 +754,17 @@ makes. Measured, serialized, as the conversation lengthens: | 200 | 168,560 | 845 | 1,690 | | 800 | 675,560 | 845 | 1,690 | -At 800 turns `full` is 64.4% of the 1 MB limit while `last_agent` is 0.1%. The difference is not a -smaller payload, it is a payload that stops growing: `last_agent` and a fixed-window `custom` filter -are both constant regardless of conversation length. - -So a workflow that approaches the limit through its own projection should change mode before -reaching for offload or retention, because those manage a cost this removes. `full` remains the -default to match core, and it is the right choice when downstream nodes genuinely need the whole -history, but it is a deliberate choice with a measurable price rather than a free default. - -Stored-id comparison is insufficient: retention removes old ids, after which a cycle would re-ingest -exactly what was evicted and oscillate instead of converging. The small position map survives -deletion. Once content is evicted, the node no longer sees it. Re-ingesting it would defeat -retention. +At 800 turns the complete `full` projection is 64.4% of the 1 MB limit while `last_agent` is 0.1%. +That result explains why projection alone is not a general transport bound: `full` is valid when a +target needs the complete conversation, yet repeatedly sending its prefix remains linear. Delta +transport keeps that semantic choice while sending only the newly visible suffix on each visit. +`last_agent` and fixed-window `custom` projections remain useful because they also bound what the +target is allowed to read, not merely how repeated context is transported. -This intentionally differs from core in one measured case. On the third visit of a `full`-mode -cycle, core in-process sends 11 messages with repeated context while durable sends 8 after dedup. In -`last_agent` mode they are identical. Each durable node also keeps history keyed by workflow instance -and executor, so its memory survives restarts independently of the workflow envelope. +Stored-id comparison at the entity remains insufficient. Retention can remove old ids, after which +a redelivered prefix would look new and be re-ingested. The small position map survives deletion and +rejects that redelivery. Once content is evicted, the node no longer sees it; accepting the old +position again would defeat retention. ## Zero-Configuration Registration @@ -655,7 +785,12 @@ What that looks like as a single decision, taken once at registration. ```mermaid flowchart TB + CHECK{"more than one
load-enabled provider?"} + REJECT["reject registration"] Q{"what did the agent
already have?"} + + CHECK -->|"yes"| REJECT + CHECK -->|"no"| Q Q -->|"nothing"| INJ["inject the durable provider, under the
source_id core's own injection would have used"] Q -->|"InMemoryHistoryProvider"| REP["replace it, preserving
source_id and skip_excluded"] Q -->|"DurableHistoryProvider, wired by hand"| KEEP["keep it, rebuilding with the mode's pruning
only when prune_excluded was left unset"] @@ -674,10 +809,11 @@ Preserving `source_id` is the load-bearing detail. `CompactionProvider` locates `history_source_id` (default `"in_memory"`), so a provider swapped in under the same id is invisible to the rest of the configuration. -Only the first load-enabled provider is considered. Core permits several, for example a primary store -plus a store-only audit provider, and the others are left exactly as configured. That is deliberate, -since substituting more than one would give two providers the same `source_id`, but it does mean an -audit or evaluation provider keeps whatever storage the user gave it and is not made durable. +Registration permits exactly one load-enabled primary history provider. A second load-enabled +provider would duplicate model context and could persist another transcript outside the primary +owner's retention, so registration rejects it rather than choosing the first silently. Additional +store-only audit or evaluation providers remain valid and keep the storage and lifecycle policy the +user configured for them. **Substitution changes where history is kept, and does not move what is already there.** Replacing an `InMemoryHistoryProvider` hands ownership of the conversation to durable entity state from that point @@ -693,10 +829,10 @@ migration path is offered for it. 1. **Who supplies conversation context?** If the agent exposes core's context-provider pipeline, the providers do, so the entity passes a session and delivers **only the new messages**. This holds whether history lives in durable state, an external store, or the model service. -2. **Who bounds entity state?** Retention does, for every configuration, because the entity records - the exchange even when another provider owns model context. What it records is not always the - content: when another provider owns the conversation, the entity keeps the envelope and forgets - the request content, since that provider's own policy is what bounds the conversation itself. +2. **Who bounds entity state?** The deployment does, by choosing `follow_compaction`, a pressure + budget, both, or neither. The entity records the exchange even when another provider owns model + context, but not always its content. The external provider's own policy remains authoritative for + the conversation it stores. The entity therefore replays its own persisted history in exactly one case, an agent that does not expose the context pipeline. Passing a session re-engages external providers and core's in-run @@ -731,8 +867,10 @@ against none of the non-streamed ones. The id is genuine and was captured correc resolves a moment later. Azure has since treated this as a service defect, and re-measuring found the chaining path fixed while `responses.retrieve` still lags. -The entity therefore re-sends the identical request a few times. That recovers the case above, -costs about a second, and requires nothing to be stored. +The entity therefore re-sends the identical request up to three times inside the same operation, +waiting 0.5, 1.0 and 1.5 seconds before the attempts. That recovers the case above without a second +transcript or an unbounded retry loop. A different error escapes immediately, and exhausting the +three matching refusals fails the turn. An id that has **genuinely expired** produces the same error and cannot be recovered that way, so those turns fail, as they do in core. The alternative would be to resend our own transcript, which @@ -756,11 +894,25 @@ duplication and type loss: - The durable history provider's own slice is **excluded** before persisting. It is derived from `conversationHistory`, so storing it would duplicate the transcript. +The excluded slice is a transient working buffer, not the persisted compaction record. On each +turn, `DurableHistoryProvider.get_messages()` rebuilds it from `conversationHistory`, including the +message ids and annotations already written there. `CompactionProvider.after_strategy` mutates that +buffer, and the durable provider reconciles those mutations back by message id before session +serialization drops the slice. The next turn therefore reconstructs the same working view without +storing the transcript twice. + Restore applies the stored state onto a session created by the agent's own `create_session()`, so the agent's session type is preserved. Core's state-type registry is process-local, so the entity pre-registers serializable types already loaded in the process before restore. Pydantic state remains a core gap because broad subclass discovery would be collision-prone. +Provider-owned, versioned snapshots are the intended contract. Each provider should decide what may +cross a durable turn and how its payload migrates. Core does not yet expose a provider version or a +snapshot / restore capability, so the first implementation keeps the JSON-compatibility check and +excludes the durable history slice from broad session serialization. The provider lifecycle work +described under core gaps must land before a `{provider, version, payload}` envelope can carry a +real guarantee. + ### Service-managed conversations When the model service stores the conversation, it identifies the thread with an id. The entity @@ -774,6 +926,11 @@ API) are routinely put back into client-side mode with `store=False`. Consulting `STORES_BY_DEFAULT` would leave such an agent with a plain in-memory provider that the durable runtime never persists, silently losing the conversation between turns. +Core permits that choice to change between runs. Durable does not migrate service-owned content +back into local history, so a client-side turn sees the same gap it would see in core. The entity's +contentless records remain available for correlation and audit but are not replayed as blank model +messages. + Core resolves this rule inside `Agent._run` and does not expose the result, so this layer **re-derives it** and can drift if core changes. *Upstream fix:* expose the resolved decision. The integration sample covers `store=False` against a store-by-default client. @@ -782,18 +939,43 @@ integration sample covers `store=False` against a store-by-default client. Compaction annotates, it does not delete. Deletion is configured at **registration** (an app-level default with a per-agent override) rather than on the agent, so the agent definition stays portable: -the same agent runs in-memory where retention has no meaning. `auto` applies no context policy, but -deletion necessarily shortens future available history. Only `follow_compaction` treats a compaction -exclusion as permission to delete. Under `auto`, the storage budget alone chooses what is removed. +the same agent runs in-memory where retention has no meaning. `retention="follow_compaction"` treats +a compaction exclusion as permission to delete. A separate `max_state_bytes` setting enables +pressure eviction and chooses its budget without changing how compaction exclusions are treated. +Both default to non-deleting behavior. + +## Follow-up Work After Approval + +This ADR records the work now so review can settle its scope. New issues will be filed after the +decision is approved. + +- **Provider lifecycle contract, upstream core.** Add concurrency-safe replace/flush, + clear/delete, resolved ownership, and versioned snapshot/restore. Core owns the abstraction and no + provider can declare a versioned snapshot today. +- **Provider-owned session snapshots, after that contract.** A `{provider, version, payload}` + envelope has no real version or migration policy until providers supply one. +- **Explicit history-owner migrate/fork, upstream core.** `store` is a core per-run option. Durable + should not reinterpret or reject it on its own. +- **Backend metadata for `max_state_bytes="backend_limit"`, where unavailable.** Direct DTS has a + known 1 MB limit. Azure Storage has blob offload, and some hosting layers do not expose the active + backend or a hard limit. +- **Cross-language state compatibility, before a PR writes new kinds.** Choose the reader-first + rollout or a new major schema version, then add bidirectional and rollback tests. +- **Move arbitrary `context_filter` execution out of orchestrator replay.** Existing issue + [#79](https://github.com/microsoft/agent-framework-durable-extension/issues/79) tracks using an + activity, which avoids replaying user I/O and side effects at the cost of a scheduling round trip. ## Out of Scope: Entity Lifetime Idle TTL and cleanup bound how many abandoned entities remain. They do not bound an actively used -entity because each interaction extends its lifetime. Cross-language TTL parity is a separate -decision. +entity because each interaction extends its lifetime. Cross-language TTL parity is tracked in +[#10](https://github.com/microsoft/agent-framework-durable-extension/issues/10) and remains a +separate decision. ## More Information +- Parent tracking: [#4, automatic compaction to stay within durable backend limits](https://github.com/microsoft/agent-framework-durable-extension/issues/4) + and [#5, external durable-agent conversation storage](https://github.com/microsoft/agent-framework-durable-extension/issues/5). - Builds on [ADR-0019](https://github.com/microsoft/agent-framework/blob/main/docs/decisions/0019-python-context-compaction-strategy.md) (context compaction strategy), which defines the in-run / pre-write / on-existing-storage compaction points and the atomic-group constraint. From 851f02c251eac6f0ef730f9bdcf60106ccc23faa Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Tue, 8 Sep 2026 13:07:04 -0500 Subject: [PATCH 07/13] docs: clarify provider-independent execution state Separate execution and delivery from transcript ownership. Clarify the A1 compatibility trade-off, custom-ID deduplication, and migration for workers and polling clients, with updated visuals. --- .../0032-durable-thread-compaction.md | 490 +++++++++++------- 1 file changed, 301 insertions(+), 189 deletions(-) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index b1fccb8..1a8bc99 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -9,8 +9,9 @@ informed: # Thread Compaction for Durable Agents and Workflows -> **How to read this.** The decision comes first. The later sections record the Python prototype -> and the gaps it exposed. **.NET is not implemented yet.** +> **How to read this.** The decision sections describe the revised target contract. The Python +> prototype still uses the combined execution/transcript layout described below. Prototype evidence +> is not validation of the revised layout. Later sections record the remaining Python and .NET gaps. > > **Naming.** .NET's `ChatHistoryProvider` and Python's `HistoryProvider` are the same concept. The > decision sections use the .NET name, and the implementation sections use the Python one. @@ -92,6 +93,9 @@ agent configuration bounds model input and the persisted store can be bounded se parallel durable-only API. - **Reuse existing core hooks.** Do not reinvent triggers, strategies or grouping. Reuse the in-run filter and the store reducer. +- **Provider-independent execution.** Correlation, completion, errors, result delivery and duplicate + suppression have the same contract regardless of the history owner. Transcript storage is a + separate responsibility, even when both live in one entity. - **Separate storage capacity from context management.** Bound model input with compaction (parity with core), raise backend capacity where possible, and configure storage deletion independently. - **Deletion is explicit and observable.** Entity state is a state bag, not an immutable system of @@ -151,6 +155,12 @@ Chosen option: **Option 6, express durable conversation storage as a core `ChatH combined with workflow context projection and delta transport (Option 4). The two solve different surfaces. +**The entity's execution and delivery contract is independent of the history provider.** Every +configuration uses the same request-level bookkeeping, original result delivery and completion +receipts. The selected history owner decides where transcript messages and compaction metadata live. +An entity-local durable transcript is one implementation of that history contract, not the entity's +execution journal. + | Surface | Mechanism | Behavior | | --- | --- | --- | | **L1, agent context** | The user's configured core `CompactionProvider` / `compaction_strategy` | Non-lossy projection of model input. The same agent configuration works durably. | @@ -158,39 +168,38 @@ surfaces. | **L3, workflow context** | Existing `AgentExecutor.context_mode` / `context_filter` projection plus per-target delta transport | Controls what crosses between executors without repeatedly sending the same prefix. This is not a core compaction hook. | | **Capacity safety** | Optional `max_state_bytes` budget | Evicts oldest groups under pressure, independently of whether compaction is configured. | -The surfaces act at different points in a single turn. +The common path below applies to durable, external and service-owned history. Workflow projection +and delta selection happen before the request reaches the entity. The two storage policies are +independent opt-ins and never rewrite the caller's result. ```mermaid -flowchart TB - CM["Workflow orchestrator, re-executed every episode
L3: context_mode / context_filter"] - - subgraph ENT["AgentEntity, one operation and one state write"] - DUP{"mailbox payload or
completed tombstone?"} - DONE["return the response or
an already-completed result"] - REC["record the request, resolve ownership for this run"] - RESP["record the response in the transcript and mailbox"] - L2["L2, if enabled: prune what compaction excluded"] - CAP["Capacity, if enabled: evict under pressure"] +sequenceDiagram + participant C as Caller / workflow + participant E as Durable entity + participant A as Agent + selected history owner + C->>E: Request and correlation ID + alt Completion already recorded + E-->>C: Original result or already-completed status + else New request + E->>E: Restore session and ingestion state + E->>A: Current input and session + Note over A: Owner supplies history
Core L1 applies where supported + A-->>E: Original response or runtime error + E->>E: Stage result, completion and session state + opt Effective eager pruning enabled + E->>E: Prune excluded local transcript groups end - - subgraph CORE["Inner agent, core pipeline unchanged"] - HP["DurableHistoryProvider, silent when the service owns the run"] - CP["L1: CompactionProvider"] - MODEL(["model call"]) + opt Pressure budget configured + E->>E: Evict eligible local transcript groups end - - STATE[("durable entity state")] - - CM -->|"context_messages"| DUP - DUP -->|"yes"| DONE - DUP -->|"no"| REC - REC -->|"session, plus only the new messages"| HP - HP --> CP --> MODEL --> RESP --> L2 --> CAP --> STATE - STATE -.->|"next turn"| HP + E->>E: Commit entity-local state together + E-->>C: Result, directly or through polling + end ``` - L3 decides what crosses between workflow nodes, L1 decides what the model reads, and the two - storage controls decide what survives. Only the storage controls delete, and each is opt-in. +L3 decides what crosses between workflow nodes, L1 decides what the model reads, and transcript +retention decides what history remains available. Mailbox expiry is a separate delivery policy. +External provider and service writes are not part of the entity's atomic commit. This gives agent-level **configuration parity**, not byte-for-byte parity in every workflow cycle. Durable workflow nodes intentionally deduplicate repeated upstream context before persisting it. The @@ -204,72 +213,59 @@ this" only under `follow_compaction`. ### Who bounds what -Every store bounds what it owns. This is the rule the rest of this section follows, and it is worth -stating plainly because it decides which copy of a conversation is authoritative. +The entity always owns request-level execution and delivery state. That includes correlation and +completion information, the original response or a retrievable reference, and session/workflow +control state. These responsibilities do not move when the history provider changes. They are not +strictly metadata-only. A waiting caller needs its answer, not just evidence it once existed. -| Where the conversation lives | What bounds it | What the entity keeps | -| --- | --- | --- | -| The customer's own store (Redis, Cosmos, file) | Their store's own policy, for example Redis `max_messages` or a Cosmos container TTL | The exchange, not the content | -| Durable entity state | Explicit `follow_compaction`, an optional pressure budget, or ultimately the backend limit | Everything, since nothing else holds it | -| The model service | The service's own retention | The exchange, not the content | -| No context pipeline at all | Explicit `follow_compaction`, an optional pressure budget, or ultimately the backend limit | Everything, since nothing else holds it | +Transcript ownership is the independent choice below. Each store bounds its own transcript. -The same four cases as a path. The branch decides where the conversation lives, and that in turn -decides what the entity keeps. +| History owner for the run | Where the transcript lives | What bounds that transcript | +| --- | --- | --- | +| `DurableHistoryProvider` | Entity-local `conversationHistory`, with messages, IDs and annotations | Explicit eager pruning, an optional pressure budget, or the backend limit | +| Redis, Cosmos, file or custom provider | The provider's chosen store | Its own policy, such as `max_messages` or container TTL | +| Model service | The service | The service's retention | +| No context pipeline | Entity-local transcript, supplied by the legacy replay path | Optional pressure eviction or the backend limit | ```mermaid flowchart TB - AGENT["Inner agent with core's context pipeline"] - NOPIPE["Agent without the context pipeline"] - SLOT{"which provider holds
the conversation?"} - - AGENT --> SLOT - SLOT -->|"durable, injected or swapped in"| ES["durable entity state
explicit retention, pressure budget, or backend limit"] - SLOT -->|"Redis, Cosmos, file, custom,
left exactly as configured"| EXT["the customer's store
bounded by their own policy"] - SLOT -->|"durable attached but silent,
the service owns this run"| SVC["the model service
bounded by the service"] - NOPIPE -->|"entity replays its own history"| ES - - ES --> KEEPALL["entity keeps the content,
because nothing else holds it"] - EXT --> KEEPENV["entity keeps the envelope,
correlation id, timestamps and message ids,
and forgets the request content"] - SVC --> KEEPENV + COMMON["Every run uses the same entity contract
Execution, delivery, session and workflow control"] + COMMON --> OWNER{"Who owns history on this run?"} + OWNER -->|"Durable provider or legacy replay"| LOCAL["Entity-local transcript
Messages, IDs and annotations"] + OWNER -->|"External provider"| EXTERNAL["Provider's store
No required entity-side message mirror"] + OWNER -->|"Model service"| SERVICE["Service transcript
Conversation ID in entity session state"] ``` -Only the durable-entity-state branch makes the entity the owner of the conversation. In the other -two the entity is a record of the exchange rather than a second copy of the content. Responses sit -outside this entirely and are kept in every branch, for the reason below. - -The entity records every exchange in every configuration, because correlation ids and response -delivery are its job and nothing else can do them. It does not have to be a second copy of the -conversation, and being one would put the customer's content under two different retention, -residency and deletion policies when they deliberately chose one store for it. So when another -provider owns the conversation, the entity keeps the envelope, the correlation id, the timestamps -and the message ids, and forgets the content. +For externally or service-owned turns, the common execution record does not require a contentless +copy of each request message or a locally generated ID purporting to identify a message in the +external store. Message-level journaling needs an explicit consumer and lifecycle, not a default +mirror. This does not remove response delivery obligations or the custom-ID deduplication dependency +described below. Existing local transcript data remains subject to its own retention and transition +rules. Choosing an external owner is not permission to discard it implicitly. -Orchestrations reach the entity through `call_entity` instead, which returns the value directly. The -same bytes then exist in two places, but they are not two copies of one thing: the orchestrator -records a **task result**, which is what makes its replay deterministic, while the entity records -**what the assistant said**, which is what the next turn's model context is built from. Neither is -removable, and the overlap is two systems recording the same event for different reasons rather than -a defect in either. +Orchestrations reach the entity through `call_entity`, which returns the value directly. The +orchestrator records that task result for deterministic replay. The entity records completion and +result delivery under the same contract as a client or HTTP call. If durable owns history, an +assistant message also belongs to its transcript. These records can contain the same payload for +different lifecycles. None should be used as the other's deletion or completion signal. **Ownership is resolved per run, as it is in core.** Core gives an explicit `store` in the effective run options precedence over the client's `STORES_BY_DEFAULT`, so an agent registered against a service-storing client can still be asked to keep one turn client-side. Durable mirrors that rule rather than pinning an owner for the session and rejecting a core-supported run option. -A durable history provider is attached at registration even for a service-storing client. It -claims the history slot before core can inject an `InMemoryHistoryProvider` on a later `store=False` -run. Persisting that injected provider with the session grew state by 321 bytes per turn in the -prototype and put the transcript outside durable retention. The durable provider instead yields no -history on runs the service owns, so the model is never sent a transcript the service already -carries. +A durable history provider is attached for a service-storing client **when no external primary +history provider is configured**. It claims the slot before core can inject an +`InMemoryHistoryProvider` on a `store=False` run. Persisting that injected provider with the session +grew state by 321 bytes per turn in the prototype and put the transcript outside durable retention. +An external primary already occupies that slot, so durable is not added alongside it. -Changing `store` does not migrate history between owners. A client-side turn after service-owned -turns therefore sees a gap, which is the same behavior as core. The entity still keeps contentless -envelopes in `conversationHistory` for correlation and audit. Their message `contents` are empty, -and the history provider omits those messages from model context rather than rebuilding blank turns. -An explicit migrate or fork operation would be a core capability, not a durable-specific -reinterpretation of `store`. +Attachment is not ownership. An attached durable provider yields no local history on a service-owned +run and is available for client-owned runs. This does not require retaining shells for messages +whose contents it never stored. Changing `store` neither imports the service transcript nor promotes +mailbox responses into history. Existing contentless records are already skipped by the prototype's +replay converters. An explicit migrate or fork operation would be a core capability, not a +durable-specific reinterpretation of `store`. ### Response delivery and duplicate suppression @@ -290,38 +286,56 @@ tombstones. They can therefore become part of the non-evictable floor and cause rather than permit duplicate execution. Automatic entity cleanup is tracked separately under entity lifetime. -The response can still participate in model context while its transcript entry survives. The -mailbox controls whether the caller can collect the result, while transcript retention controls -whether a later model call sees it. An implementation may share the underlying payload while both -references are live, but deletion decisions remain independent. +The mailbox preserves the original success or runtime-error result, including its response metadata. +Compaction may annotate, summarize or remove transcript messages without changing that result. +Errors that describe a failed entity operation and completion receipts are not model context. + +The selected owner may also retain the assistant response as history. The mailbox determines +whether the caller can collect it, while that owner's retention determines whether a later model +call sees it. Immutable payload storage may be shared, but a mailbox reference must remain readable +through its delivery window even if the transcript entry is deleted. It cannot be only a pointer +into an evictable transcript. Transcript pruning or clearing never clears completion receipts. + +These delivery guarantees begin with a successful entity commit. If capacity prevents that commit, +there may be no room to persist even an error response. Surface the failure through the operation's +error channel where available and diagnostics. A signal caller polling state may instead time out, +not receive a durable capacity-error result. Do not write a success or completion receipt for an +uncommitted turn. Retrying it can repeat side effects, as described under worker failures. ### What the entity persists -Retention, workflow deduplication and session continuity all read and write the same entity state, -so it is worth seeing its shape before the sections that manipulate it. +The revised design has three logical slices. The diagram groups responsibilities, not a requirement +to introduce new nested JSON objects or move `conversationHistory` to a new field. ```mermaid flowchart LR - D["DurableAgentState.data"] - D --> CH["conversationHistory
model transcript and exchange record"] - D --> MB["responseMailbox
response delivery"] - D --> CC["completedCorrelations
duplicate suppression"] - D --> SE["session
provider state bag,
service conversation id"] - D --> IP["ingestedPositions
workflow redelivery safety net"] - D --> TR["truncation
evictedMessageCount,
firstEvictedAt, lastEvictedAt"] + ENTITY["One entity / one session
One total size budget"] + ENTITY --> EXEC["Execution and delivery, every owner
Request-level bookkeeping
responseMailbox + completedCorrelations"] + ENTITY --> CONTROL["Session and workflow control, as needed
session + ingestedPositions
Custom-ID deduplication bookkeeping"] + ENTITY --> HISTORY["Local transcript, when used
conversationHistory
Messages, IDs, annotations + truncation"] ``` -The fields separate six lifecycles that one conversation array cannot safely own. Transcript -retention, response delivery, duplicate suppression, provider state, workflow redelivery and the -audit evidence of truncation can now expire or fail independently. Pressure eviction deletes only -from `conversationHistory`. Mailbox payloads follow their delivery expiry, while correlation -tombstones are non-evictable proof that an operation already completed. +Execution and delivery have the same semantics in every configuration, not necessarily identical +bytes or a permanently populated record for every field. History providers do not own correlation +receipts or polling. A durable provider owns the read, append and reconciliation behavior for its +entity-local transcript just as an external provider owns those operations for its store. The +entity runtime remains the physical writer of all entity-local slices at the operation boundary. + +There must be exactly one append path for each transcript input and output. The prototype instead +appends in `AgentEntity`, leaving `DurableHistoryProvider.save_messages()` as a no-op. The revised +implementation must rewire that ownership without appending from both places or losing provider +storage choices. This does not require an upstream lifecycle API for existing external providers. -`ingestedPositions` survives eviction deliberately, because a watermark stored among the messages -would be removed with them and a redelivered workflow delta would then be accepted twice. `session` -excludes the durable provider's own history slice, since `conversationHistory` is the record of -truth and carrying both would store the conversation twice. `truncation` exists because deletion -has to be discoverable afterwards, and its absence says nothing has been dropped. +`ingestedPositions` and any required custom-ID deduplication record live outside the evictable +transcript. `session` carries provider state and any service conversation ID, excluding the durable +provider's transient working buffer. `truncation` records transcript loss, not request completion. +Only eligible local transcript groups are candidates for eager pruning or pressure eviction. + +**One budget, not a budget per slice.** Measure the whole serialized entity, including delivery +payloads, completion receipts, session state, cursors and any compatibility data. Separate fields do +not remove duplicate bytes from this calculation. If protected state alone cannot fit, report +capacity failure rather than evicting delivery obligations or pretending an external store can be +trimmed by entity retention. Mailbox expiry remains an independent cleanup policy. Each entity holds one durable agent session. A new standalone session gets a new entity key, and a workflow agent node is keyed by workflow instance plus executor. The 1 MB DTS limit and any @@ -329,6 +343,18 @@ workflow agent node is keyed by workflow instance plus executor. The 1 MB DTS li Old sessions occupy separate entities and do not reduce the budget of later sessions. How long those abandoned entities remain is the separate entity-lifetime concern described below. +**Why the prototype kept the combined layout.** A1 changed new writes by clearing externally owned +request content while preserving `conversationHistory` and its message envelopes. We chose it over +A2, relocating the transcript into a separate history field, to avoid a transcript-location +migration for existing entities and workflows paused across deployments. It was a compatibility +trade-off, not evidence that every empty message envelope had a delivery consumer. + +The mailbox and completion-receipt changes already require a compatible transition. The revised +target therefore separates responsibilities consistently while retaining the transcript field +where possible. Physical relocation is not a prerequisite, and neither this separation nor a new +schema version makes old worker and polling behavior compatible automatically. The state-evolution +section defines that transition. A1 remains the prototype, not the target storage contract. + ### Retention Deletion has two independent controls. `retention` says whether a compaction exclusion is also @@ -432,6 +458,8 @@ optional pressure retention are inherited by workflow agent executors**. The wor - **Configuration parity.** Existing agent compaction configuration works durably without changing the agent. Retention does not choose the current model projection. +- **Consistent execution state.** Request completion and delivery do not change when transcript + ownership changes. External and service-owned turns do not require a metadata-only message mirror. - **Independent deletion policies.** Users can follow their own compaction exclusions without enabling pressure eviction, or enable pressure eviction while preserving those exclusions in the stored transcript. @@ -441,8 +469,10 @@ optional pressure retention are inherited by workflow agent executors**. The wor - **Delivery correctness has its own cost.** Mailbox entries and completed-correlation tombstones cannot be pressure-evicted. They consume part of the floor so the system fails rather than silently re-executing a completed request. -- **Larger entity change.** The history-provider design must preserve response polling and the - entity's conversation record. +- **Compatible transition, not a field move.** Separating lifecycles requires old/new response + lookup and in-flight upgrade tests even if the transcript keeps its existing field name. +- **Independent external commits.** Local slices commit together, but external history writes can + succeed before an entity commit. Uniform bookkeeping does not create a distributed transaction. - **Python-only eager pruning.** .NET would duplicate the transcript if it persisted current compaction state (gap 4), so a .NET implementation could use pressure retention but not L2 yet. - **Core workarounds.** Python must publish and reconcile a working buffer because core binds @@ -459,21 +489,37 @@ twenty turns against a reduced budget. Scheduler integration covers persisted an message ids, external-provider session identity, schema conformance, downstream workflow context, and Redis as the sole owner of an external conversation. +The prototype's `_to_message()` and `_to_replayable_message()` already skip messages with no +replayable content. The revised tests must preserve that behavior while changing storage layout. +The test named `test_request_message_ids_survive_for_deduplication` checks request presence and +roles, not ID-based repeated input. It does not prove that all metadata-only envelopes are needed. + **Required for the revised implementation.** Not covered by the prototype yet. -- Response mailbox expiry and completed-correlation tombstones, including a redelivery after the - transcript response has been evicted. +- The same execution/delivery contract across durable, external, service-owned and legacy agents, + including success, errors, polling, duplicate correlations and cold reloads. +- Immutable mailbox responses and completed-correlation receipts after annotation changes, summary + insertion, transcript pruning, delivery expiry and transcript clearing. +- Exactly one transcript append path, preserving input/output storage choices, stable message IDs, + annotations and ordering without adding an external-provider mirror. - Independent eager-pruning and pressure controls, the `"backend_limit"` sentinel, configurable - watermarks, and a floor that cannot trigger futile deletion. -- Contentless envelope suppression when a session changes from service-owned to client-owned. -- Per-target workflow delta transport across cycles, fan-out, fan-in and orchestration replay. + watermarks, and whole-entity capacity checks for metadata-only and oversized-response floors. +- Per-run `store` transitions across cold reloads, with and without service-issued IDs, preserving + current input and session state without synthesizing history from delivery records. +- Per-target workflow delta transport across cycles, fan-out, fan-in and replay, plus custom-ID, + missing-ID and fully repeated context cases distinct from duplicate request delivery. - Registration failure for more than one load-enabled history provider. +- Legacy-state reading, idempotent conversion, response availability for old/new pollers, resumed + HITL workflows and supported rollback, including a new request after rollback. Include legacy + responses partially altered by compaction, Python/.NET rewrites and unknown-data preservation. +- Failure injection around local commit and external-provider writes. Verify that uncommitted side + effects are not represented as protected completed operations and specify poller timeout behavior + when capacity prevents even a durable error response. **Longer-term validation.** Tracked here until the ADR is approved and follow-up issues are filed. - Retention crossing the real scheduler limit against a live backend, rather than a reduced budget in process. -- Bidirectional Python/.NET state tests, including unknown entry-kind preservation and rollback. - The .NET realization and its compaction-state blocker (gap 4). - Blob offload (Option 7) against a real scheduler. It remains unreachable through Durable Functions Python 1.x and the 2.x preview (gap 6). @@ -488,6 +534,10 @@ and Redis as the sole owner of an external conversation. future LLM reducer must give summaries stable identities and be tested across retries. - Response delivery and duplicate suppression do not depend on transcript retention. Pressure eviction cannot remove a live mailbox entry or the only completed-correlation tombstone. +- No consumer of execution state may depend on the primary history provider's transcript layout. + The migration reader supplies legacy lookup behavior until the versioned transition completes. +- Commit entity-local execution, session, ingestion and transcript changes at the same operation + boundary. External stores and model/tool effects are not in that transaction. - Registration permits exactly one load-enabled primary history provider. Additional providers are store-only sinks. - Workflow projection preserves `context_mode` semantics, while a replay-derived per-target cursor @@ -626,22 +676,62 @@ converts `$type` through an enum that contains only those two values. Writing `e `compaction` before both readers understand them would therefore break a mixed-version worker and a rollback to the previous Python package. +**Compatible reading includes behavior, not just JSON.** The prototype's polling paths use +`try_get_agent_response()` to find responses in `conversationHistory`. A client that only preserves +an unknown `responseMailbox` field would still fail to find a moved response. Workers, SDK clients +and HTTP polling code must understand both representations before a writer stops emitting the +legacy delivery representation. + New entry kinds and lifecycle fields use a two-phase rollout. -1. Ship readers in both runtimes that accept the new fields, preserve unknown optional data, and - round-trip an unknown entry as raw JSON without admitting it into model context. -2. Only after those readers are available may a writer persist `errorResponse`, `compaction`, - `responseMailbox`, `completedCorrelations` or other new state shapes. +1. Ship compatible readers and response lookup in both runtimes. They accept legacy and revised + layouts, preserve unknown optional data, and keep unknown entry kinds out of model context. + Legacy state resolves completion from recorded response entries. Revised state resolves it from + the mailbox and completion receipts, distinguishing expired delivery from work not yet completed. + Workers must also maintain the revised write contract when handling an already-converted entity. +2. After every supported worker and response-reading client meets that reader floor, enable the + revised writer. A worker cannot inspect the versions of its peers or clients, so this is a + release and deployment gate, not a runtime handshake. -Phase 1 ships as a separate compatibility change before any phase 2 writer. All workers sharing a -task hub must move to that reader floor before a phase 2 package is deployed. A worker cannot -inspect the versions of its peers, so this is a release and deployment gate rather than a runtime -handshake. +```mermaid +flowchart TB + READERS["Deploy dual-layout workers and polling clients"] + READERS --> READY{"All supported readers upgraded?"} + READY -->|"No"| OLD["Keep legacy writes"] + READY -->|"Yes"| NEW["Enable revised writer"] + NEW --> MAP["Normalize existing state idempotently
Preserve results, IDs and session control"] + MAP --> COMMIT["Commit revised layout at an operation boundary
Keep the transcript location where possible"] + COMMIT --> ROLLBACK["Rollback only to compatible workers and clients
Workers must preserve revised writes"] +``` -Rollback is supported only to a reader from phase 1 or later. If that staged rollout is not -possible, the writer must use a new major schema version and the runtime must gate the write rather -than relying on the current major-only read check. Bidirectional tests must cover Python-written -state read and rewritten by .NET, the reverse direction, unknown entry preservation, and rollback. +An in-flight workflow can resume on a new deployment with an old entity. Transition code must read +that state without replaying model or tool calls just to convert it. Conversion may be lazy at the +next entity operation, but repeated conversion must not duplicate messages, mailbox entries or +completion receipts. Preserve recorded outcomes, correlations, message IDs, order, annotations, +session state and ingestion bookkeeping. Keep `conversationHistory` in place where possible rather +than requiring a bulk transcript relocation. + +Only recorded outcomes justify backfilled completion receipts. If old retention already removed an +outcome, migration cannot reconstruct it or claim that duplicate suppression covered that request. +An existing response may itself have been partially pruned or annotated. Preserve its available +payload and completion evidence, without claiming to reconstruct the original full response. +Immutable original-result guarantees apply to revised writes, not retroactively to changed data. +Legacy response expiry needs an explicit transition policy and grace period, not immediate expiry +merely because an old response predates the new policy. Once converted, an expired delivery must not +fall back to a transcript response and silently become available again. The schema/layout version, +not the absence of one optional mailbox field, identifies which lookup contract applies. + +Stage conversion and the operation's entity-local changes before committing them together. Validate +the whole serialized size, including any temporary compatibility copies, and leave the last +committed state intact if conversion cannot fit. Do not migrate an external transcript or infer its +ownership from a locally generated message ID. + +Rollback is supported only to versions that preserve both lookup and write semantics for converted +entities. Read-only tolerance is insufficient if the next operation writes its response only to the +old transcript. If a staged rollout is not possible, a new major schema version and gated deployment +are required. A version bump alone does not make old workers or clients compatible. Required tests +include paused HITL resumes, old/new polling, repeated conversion, a new request after rollback, +polling after transcript pruning, Python/.NET round-trips and unknown-data preservation. ## L3 Realization: Workflow Context Parity @@ -652,26 +742,27 @@ flowchart TB subgraph ORCH["Durable workflow orchestrator, re-executed every episode"] FC["full_conversation"] PROJ["L3: context_mode / context_filter
full, last_agent, custom"] - DELTA["select the unseen suffix for this target
with a replay-derived target, producer cursor"] - FC --> PROJ --> DELTA + DELTA["Select unseen positions for this target
Replay-derived target and producer cursor"] + FC --> PROJ --> DELTA end - subgraph NODE["Agent node, the ordinary durable agent path"] - GUARD["ingestedPositions
reject a redelivered delta"] - ENTITY["AgentEntity, one per node
its own conversationHistory"] - INNER["inner agent
L1, L2 and retention all inherited"] - GUARD --> ENTITY --> INNER + subgraph NODE["Agent node, the same execution contract as standalone"] + GUARD["ingestedPositions
Reject repeated positions"] + ENTITY["AgentEntity for this workflow node
Execution, delivery and session control"] + INNER["Inner agent + selected history owner
Configured compaction and retention apply"] + GUARD --> ENTITY --> INNER end - DELTA -->|"only new context_messages,
stamped wf executor position"| GUARD + DELTA -->|"New context_messages
Stamped workflow identities"| GUARD INNER -->|"response"| FC ``` Because a node runs the same `DurableAIAgent` to `AgentEntity` to inner agent path as a standalone durable agent, everything in the first diagram still applies inside it. Only the projection and the -delta transport are workflow-specific. Each node keeps its own history, keyed by workflow instance -and executor, so nodes do not share a conversation and their memory survives restarts independently -of the workflow envelope. +delta transport are workflow-specific. Each node has an entity identity scoped to workflow instance +and executor. Its transcript remains with its selected history owner, rather than becoming another +entity-local copy when that owner is external. Session identity and ingestion state survive restarts +independently of the workflow envelope. In-process workflows give a downstream `AgentExecutor` the upstream conversation through `AgentExecutorResponse.full_conversation`, governed by `context_mode` (`full` | `last_agent` | @@ -681,8 +772,9 @@ message's text**, so a downstream agent lost everything earlier nodes produced. Agent-level compaction needs no workflow-specific work: `AgentExecutor` passes its own session to `agent.run()`, so the agent's `CompactionProvider` runs normally. Inter-executor context has no core compaction hook. Durable instead honors the existing `context_mode` and invokes `context_filter` for -`custom` mode, then sends the projection as `RunRequest.context_messages`. Those messages become part -of the request entry and are visible to agent-level compaction. +`custom` mode, then sends the projection's delta as `RunRequest.context_messages`. The received +messages are invocation input, visible to supported agent-level compaction. Their transcript storage +follows the selected owner, not a universal request-message journal in the entity. ### `context_filter` must be pure under durable @@ -741,6 +833,15 @@ an at-least-once redelivery of a delta. Pressure retention never removes that po entity is already ahead of a replay-derived transport cursor, it drops the repeated positions and accepts only newer ones; neither side asks for an evicted prefix to be sent again. +**Custom message IDs need an explicit deduplication record.** The prototype handles IDs outside the +`wf_{executor}_{position}` format through a `known_ids` lookup built from stored message envelopes. +Removing those envelopes changes that fallback. The revised implementation must preserve or replace +this lookup in workflow/control state before omitting externally owned message records. Specify its +identity scope and retention independently of transcript compaction, and test repeated custom input +with a new request correlation as well as redelivery of an already-completed request. They are +different forms of deduplication. No entity-local ID is assumed to identify a message in an external +store, and arbitrary custom IDs cannot be treated as monotonic workflow positions. + ### Projection and delta transport bound different costs `context_mode` is a semantic choice about what a target may see. Delta transport is a capacity @@ -772,38 +873,38 @@ Registration must not require edits to an agent that already works in core. The substitutes history at construction time. It shallow-copies the agent when substitution is needed, so the caller's instance remains unchanged. +These rules select a history adapter, not a different execution-state layout. An external primary +already supplies the history-provider role. No durable provider is added merely to record execution +metadata, since the entity owns that responsibility directly in every configuration. + | User configured | Durable behavior | | --- | --- | -| Nothing | Inject a durable history provider, using the `source_id` core's auto-injected provider would have, so default-wired compaction still resolves. No compaction by default (same as core). | +| No load-enabled primary, including configurations with only store-only sinks | Inject a durable history provider using core's default `source_id`. Preserve the sinks. No compaction by default (same as core). | | `InMemoryHistoryProvider` (± compaction) | Replace with the durable provider, **preserving `source_id` and `skip_excluded`** so any attached `CompactionProvider` keeps working untouched. | | `DurableHistoryProvider` wired by hand | Keep it. Rebuild it with the retention mode's pruning only when `prune_excluded` was left unset, since an unset value is the absence of an opinion rather than a decision. A pinned value wins over the mode. | -| Cosmos / Redis / file / custom provider | **Leave alone.** The user chose where their conversation lives, and durable still supplies execution durability. Core injects nothing when one of these is present, so there is no slot to claim. | -| Service-managed history | **Inject a provider anyway.** The service owning the conversation is a property of each run, not of the registration, and a run passing `store=False` would otherwise be answered by a provider core injects and retention cannot see. The provider yields no history on runs the service does own. | +| Cosmos / Redis / file / custom load-enabled primary | **Leave alone.** The user chose the store, so do not add a second primary. Execution recording remains the entity's job. | +| Service-storing client without an external primary | **Keep a durable provider available.** It pre-empts core's in-memory injection on client-owned runs and is silent on service-owned runs. Attachment does not require a local transcript for those service-owned turns. | | Agent without the core context pipeline | **Leave alone.** Falls back to replaying persisted history. | What that looks like as a single decision, taken once at registration. ```mermaid flowchart TB - CHECK{"more than one
load-enabled provider?"} - REJECT["reject registration"] - Q{"what did the agent
already have?"} - - CHECK -->|"yes"| REJECT - CHECK -->|"no"| Q - Q -->|"nothing"| INJ["inject the durable provider, under the
source_id core's own injection would have used"] - Q -->|"InMemoryHistoryProvider"| REP["replace it, preserving
source_id and skip_excluded"] - Q -->|"DurableHistoryProvider, wired by hand"| KEEP["keep it, rebuilding with the mode's pruning
only when prune_excluded was left unset"] - Q -->|"Redis, Cosmos, file, custom"| LEAVE["leave it alone, core injects nothing
when one is present, so there is no slot to claim"] - Q -->|"no context pipeline at all"| NONE["leave the agent alone,
the entity replays its own history instead"] - - INJ --> SRC["an attached CompactionProvider keeps working,
because it resolves history by source_id"] - REP --> SRC + PIPE{"Core context pipeline?"} + PIPE -->|"No"| LEGACY["Keep legacy entity-local replay"] + PIPE -->|"Yes"| COUNT{"Load-enabled primary providers?"} + COUNT -->|"More than one"| REJECT["Reject registration"] + COUNT -->|"None, including sink-only"| INJECT["Inject durable provider
Preserve store-only sinks"] + COUNT -->|"One"| TYPE{"Which primary?"} + TYPE -->|"In-memory"| REPLACE["Replace with durable provider
Preserve source_id and skip_excluded"] + TYPE -->|"Durable"| KEEP["Keep explicit pruning choice
Otherwise inherit retention setting"] + TYPE -->|"External"| EXTERNAL["Keep external provider
Do not add durable alongside it"] ``` -Substitution is a registration-time decision, but **who serves history is a per-run one**. A -service-managed agent still gets a provider attached here, and the previous diagram shows why that -provider then stays silent on the runs the service actually owns. +Substitution is a registration-time decision, but **who serves history is a per-run one**. For a +service-storing client without an external primary, an attached durable provider can be active or +bypassed for history. These are not retention modes and do not change the common execution record. +An external provider is left in place instead, not supplemented with a second primary. Preserving `source_id` is the load-bearing detail. `CompactionProvider` locates history through `history_source_id` (default `"in_memory"`), so a provider swapped in under the same id is invisible @@ -821,18 +922,21 @@ on. Anything the caller had already accumulated in that provider stays where it across, so the durable conversation begins empty. In practice this is invisible, because an in-memory provider's contents do not survive the process that registered the agent, and registration happens before any turn is taken. It would be visible to a caller who populated a provider in-process and then -registered the same instance with a worker, which is worth knowing but is not a supported pattern. No -migration path is offered for it. +registered the same instance with a worker, which is worth knowing but is not a supported pattern. +Importing that in-memory content is not part of the persisted-state upgrade contract. Existing +durable entity state still requires the compatible transition described above. ### Entity Context Ownership 1. **Who supplies conversation context?** If the agent exposes core's context-provider pipeline, the providers do, so the entity passes a session and delivers **only the new messages**. This holds whether history lives in durable state, an external store, or the model service. -2. **Who bounds entity state?** The deployment does, by choosing `follow_compaction`, a pressure - budget, both, or neither. The entity records the exchange even when another provider owns model - context, but not always its content. The external provider's own policy remains authoritative for - the conversation it stores. +2. **Who records execution and delivers results?** The entity, using the same correlation, outcome + and receipt contract in every case. That contract does not require a record of every externally + owned message. Necessary workflow/custom-ID bookkeeping has its own lifetime. +3. **Who bounds storage?** The deployment selects eager pruning and pressure controls for eligible + entity-local history. The budget counts all local slices. An external provider's policy remains + authoritative for its transcript and is not changed by these controls. The entity therefore replays its own persisted history in exactly one case, an agent that does not expose the context pipeline. Passing a session re-engages external providers and core's in-run @@ -842,9 +946,10 @@ full entity identity, name plus key, so workflow nodes cannot share an external- ### What survives a worker failure, and what does not Durability here is **per operation**, not per step within one. An entity operation records the -request, invokes the agent, records the response and persists once. State is written at operation -boundaries, so a worker lost mid-turn loses that turn's work and the operation is retried from its -start. Provider state and the conversation are consistent afterwards because neither was written. +request, invokes the agent and stages its outcome. Entity-local execution, session, ingestion and +transcript changes commit together at the operation boundary. A worker lost before that commit +leaves the previous local state intact and the operation may be retried from its start. A completion +receipt is evidence only of a committed outcome, not of every step attempted within an operation. What that does not give is exactly-once execution of the side effects inside a turn. A tool call that has already run, or a model call already billed, will run again on retry. This is the same @@ -853,10 +958,12 @@ because a reader could reasonably assume that "durable" means checkpointing betw does not, and an agent whose tools are not idempotent should say so through the usual mechanisms rather than expecting the entity to protect it. -The one asymmetry is a service that stores conversations. If the model service accepted the turn -before the worker died, the service has a turn the entity did not record, and the retry adds another -one. The entity cannot see that, which is a further reason a conversation id refused by the service -is retried in place rather than worked around. +External history providers have their own commit boundaries. Redis, a custom store or the model +service may accept a write before the entity commits. A retry can therefore repeat an external write +or a tool side effect even though local slices are consistent. The common execution contract does +not provide a cross-store transaction. Completion receipts suppress repeats of committed requests, +not side effects from interrupted, uncommitted turns. This limitation applies to external providers +as well as service-managed history. ### A conversation id the service refuses @@ -883,16 +990,17 @@ rather than a silent cost. ### The session is persisted, not just its conversation id Providers use session state for data that must survive turns, including pending approvals. Because -the entity creates a session per operation, it persists the serialized session rather than selecting -fields from it. "Serialized session" here means what `AgentSession.to_dict()` produces, which is a -lightweight container: identifiers plus a per-provider state bag. It is not the conversation, and -the exact shape belongs to the hosting runtime rather than to this contract. Two details prevent -duplication and type loss: +the entity creates a session per operation, the current Python compatibility mechanism persists +`AgentSession.to_dict()`. It carries identifiers and a provider state bag, not necessarily small or +metadata-only. Logical slices do not remove message copies a provider keeps in +that bag. The full payload counts toward the entity budget, and the hosting runtime owns its shape. +Two details avoid an unnecessary local transcript copy: - The service-issued conversation id needs no bespoke field of its own - it is already part of `AgentSession.to_dict()`. -- The durable history provider's own slice is **excluded** before persisting. It is derived from - `conversationHistory`, so storing it would duplicate the transcript. +- The durable history provider's transient session slice is **excluded** before persisting. It is + derived from the local transcript, so storing it would duplicate that transcript. This is not the + durable transcript slice itself. The excluded slice is a transient working buffer, not the persisted compaction record. On each turn, `DurableHistoryProvider.get_messages()` rebuilds it from `conversationHistory`, including the @@ -920,16 +1028,17 @@ creates a fresh session per operation, so that id is **persisted in durable stat the next turn** as part of the serialized session. Without it, every turn would start a new thread. Whether the service owns history is decided with **core's precedence, not the client class alone**. -An explicit `store` in the agent's options wins, and only when it is unset does the client's -`STORES_BY_DEFAULT` apply. This matters because clients that store by default (such as the Responses -API) are routinely put back into client-side mode with `store=False`. Consulting only -`STORES_BY_DEFAULT` would leave such an agent with a plain in-memory provider that the durable -runtime never persists, silently losing the conversation between turns. - -Core permits that choice to change between runs. Durable does not migrate service-owned content -back into local history, so a client-side turn sees the same gap it would see in core. The entity's -contentless records remain available for correlation and audit but are not replayed as blank model -messages. +Resolve `store` from run options, then agent defaults, then the client's `STORES_BY_DEFAULT`. +Without a configured primary provider, a run using `store=False` could otherwise cause core to +inject an in-memory history slice into session state. Keeping the durable provider available avoids +that separate transcript outside local transcript retention. An external primary already prevents +that injection, so it needs no additional durable provider. + +Core permits that choice to change between runs. Durable preserves the service conversation ID and +honors effective options, but does not import service content, replay mailbox results as history, +or create a metadata-only transcript to represent missing content. The existing Python replay +converters already skip contentless legacy records. The bounded service-ID retry described above +uses the current invocation, not a mirror of past request messages. Core resolves this rule inside `Agent._run` and does not expose the result, so this layer **re-derives it** and can drift if core changes. *Upstream fix:* expose the resolved decision. The @@ -951,7 +1060,8 @@ decision is approved. - **Provider lifecycle contract, upstream core.** Add concurrency-safe replace/flush, clear/delete, resolved ownership, and versioned snapshot/restore. Core owns the abstraction and no - provider can declare a versioned snapshot today. + shared provider-version/snapshot contract is exposed today. This does not block the logical + execution/transcript separation or change an existing external provider's storage API. - **Provider-owned session snapshots, after that contract.** A `{provider, version, payload}` envelope has no real version or migration policy until providers supply one. - **Explicit history-owner migrate/fork, upstream core.** `store` is a core per-run option. Durable @@ -959,8 +1069,10 @@ decision is approved. - **Backend metadata for `max_state_bytes="backend_limit"`, where unavailable.** Direct DTS has a known 1 MB limit. Azure Storage has blob offload, and some hosting layers do not expose the active backend or a hard limit. -- **Cross-language state compatibility, before a PR writes new kinds.** Choose the reader-first - rollout or a new major schema version, then add bidirectional and rollback tests. +- **State and response-consumer compatibility, before revised writes.** Ship dual-layout workers + and polling clients, idempotent legacy conversion and the supported rollback contract. Retain + transcript location where possible. Verify in-flight/HITL resumes and bidirectional state tests + before enabling writers, not as a post-release cleanup. - **Move arbitrary `context_filter` execution out of orchestrator replay.** Existing issue [#79](https://github.com/microsoft/agent-framework-durable-extension/issues/79) tracks using an activity, which avoids replaying user I/O and side effects at the cost of a scheduling round trip. From f8ca89910b155429a69da823fe2e57cc1447797f Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Tue, 8 Sep 2026 15:36:07 -0500 Subject: [PATCH 08/13] docs: streamline ADR 0032 for standalone review Separate the proposed contracts from prototype evidence, consolidate repeated explanations, and preserve the existing diagrams and compatibility requirements. --- .../0032-durable-thread-compaction.md | 1597 +++++++---------- 1 file changed, 632 insertions(+), 965 deletions(-) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index 1a8bc99..fef9766 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -9,168 +9,230 @@ informed: # Thread Compaction for Durable Agents and Workflows -> **How to read this.** The decision sections describe the revised target contract. The Python -> prototype still uses the combined execution/transcript layout described below. Prototype evidence -> is not validation of the revised layout. Later sections record the remaining Python and .NET gaps. -> -> **Naming.** .NET's `ChatHistoryProvider` and Python's `HistoryProvider` are the same concept. The -> decision sections use the .NET name, and the implementation sections use the Python one. - -## Context and Problem Statement - -Long-running **durable** agents and workflows accumulate conversation history in durable -storage and replay it on every turn. Durable agents persist a full `ConversationHistory` in -entity state (`AgentEntity` → `DurableAgentState`). Durable workflows persist inter-executor -messages (`AgentExecutor.full_conversation`) as checkpointed envelopes. An in-memory agent keeps its -history in process RAM, where it disappears when the process recycles. Durable history instead -survives restarts and is reloaded on later turns. - -It helps to separate **three distinct pressures**, because they have different owners. - -| Pressure | What bounds it | Same in core? | Owner | -| --- | --- | --- | --- | -| **Context window**, the model's max input per call | the model | **Yes**, identical in core and durable | Compaction (in-run filter) | -| **Token cost / latency**, resending history each turn | tokens billed / round-trip | **Yes**, same mechanism | Compaction (in-run filter) | -| **Storage capacity**, the cumulative persisted state | backend state-size limit | **No**, durable-only | Backend offload and explicit durable retention | - -The first two are per-operation and identical in both runtimes. The third is cumulative. -`ConversationHistory` is one blob appended to every turn and re-persisted whole, so it is bounded by -what the backend will store, and the two backends fail differently. **Durable Task Scheduler caps a -message at 1 MB.** The Azure Storage backend has no hard cap, because it compresses anything over -45 KB into a `-largemessages` blob, but it pays for size in CPU, I/O and memory. So one -backend stops working at the limit and the other degrades toward it, while a core process is bounded -only by RAM and resets on restart. - -**Storage capacity is an infrastructure concern, not a context-window concern.** It is relieved -first by raising the ceiling where blob offload is available and only then by deleting. A tool for -bounding what the model reads is not a tool for bounding what the backend holds. - -Core MAF already has a compaction system ([ADR-0019](https://github.com/microsoft/agent-framework/blob/main/docs/decisions/0019-python-context-compaction-strategy.md), -.NET `Microsoft.Agents.AI.Compaction`, Python `agent_framework._compaction`) with **two hooks**. - -1. **In-run filter.** A `CompactionProvider` (`AIContextProvider`) / `compaction_strategy` runs - before each model call. It is **non-lossy**. It filters the projection sent to the model and - stores incremental group state in the `AgentSession.StateBag`, leaving the underlying store - untouched. This hook works with **any** history provider, since it acts on the messages already - loaded into the invocation context. - - Non-lossy has a storage consequence worth stating, because durable is where it is first felt. A - strategy that marks messages excluded and appends a summary standing in for them **grows** the - stored conversation: the originals remain and the summary is added. Measured over six turns, a - durable conversation went from 3,422 bytes to 10,177 with such a strategy enabled. This is not - something the durable layer introduces. The identical strategy against core's own - `InMemoryHistoryProvider` grew 809 bytes to 1,087. Durable only changes the consequence, because - it persists the result against a hard backend limit rather than holding it in process memory. - `retention="follow_compaction"` is the answer for anyone who wants compaction without paying for - it in storage: the same six turns end at 2,296 bytes, below the 3,422 they would have reached - with no compaction at all. With the default `retention="keep_all"`, compaction exclusions remain - non-lossy. A separate `max_state_bytes` budget can evict under pressure whether or not compaction - is configured. -2. **Store reducer.** **Lossily** rewrites the stored conversation, applying the same strategies at - the store instead of at the model call. Unlike the in-run filter, this hook is tied to a specific - storage mechanism in both languages. .NET exposes an `IChatReducer` on `InMemoryChatHistoryProvider` - only (bridged from any strategy by `strategy.AsChatReducer()`), and Python's - `CompactionProvider.after_strategy` reads the messages out of session state. Neither offers it to - a provider backed by anything else - see "Core Interface Gaps" below. - -The durable layer benefited from **neither**, because `AgentEntity` **bypassed the history -provider**: it created a fresh session per operation (so the StateBag - and any history provider -store or reducer in it - was discarded) and fed `ConversationHistory` directly as input messages. -Both the in-run filter's incremental state and the store reducer were thrown away every turn. - -The goal is **configuration parity**: a user's core compaction config must carry over to a durable -entity or workflow **unchanged**, reusing the same strategies and hooks on the durable runtime, -without a parallel durable compaction API. Storage retention is a separate deployment policy because -it has no meaning for an in-memory agent. - -**How should core compaction be reused on the durable runtime, in both .NET and Python, so the same -agent configuration bounds model input and the persisted store can be bounded separately?** - -## Decision Drivers - -- **Configuration parity.** The same core compaction config (strategies, `CompactionProvider`, - `IChatReducer`) must apply unchanged when moving core → durable entity → durable workflow. No - parallel durable-only API. -- **Reuse existing core hooks.** Do not reinvent triggers, strategies or grouping. Reuse the in-run - filter and the store reducer. -- **Provider-independent execution.** Correlation, completion, errors, result delivery and duplicate - suppression have the same contract regardless of the history owner. Transcript storage is a - separate responsibility, even when both live in one entity. -- **Separate storage capacity from context management.** Bound model input with compaction (parity - with core), raise backend capacity where possible, and configure storage deletion independently. -- **Deletion is explicit and observable.** Entity state is a state bag, not an immutable system of - record, so deleting from it is legitimate. The user must opt in either by following their own - compaction exclusions or by setting a pressure budget. Every deletion remains observable. -- **Determinism and idempotency.** Durable entity operations can be retried, so a lossy reducer - (especially LLM summarization) must not corrupt or diverge persisted state across retries. -- **Message-list correctness.** Preserve atomic groups (assistant tool-call plus tool-result, and - reasoning pairings) so the model input stays valid. -- **Cover both surfaces.** Durable agents **and** durable workflows, in **both** languages. Core's - compaction system is agent-level, so a workflow agent node inherits it unchanged. The conversation - chained *between* nodes is governed by `AgentExecutor`'s `context_mode` / `context_filter` seam, - which is a plain callable rather than the compaction system. That difference is real and is called - out rather than papered over. -- **Defer when the model provider owns the conversation.** When the chat client keeps history on the - service, the client holds no history for core compaction. "Service" here means the model provider, - not the durable entity. The entity's own durable record still follows its retention policy. +## Decision Summary + +Use core's history-provider abstraction for durable conversation storage, together with workflow +context projection and per-target delta transport (Options 6 and 4). Keep execution and result +delivery independent of transcript ownership. + +- The entity owns request correlation, completion, original result delivery and duplicate-request + suppression for every history configuration. +- The selected history owner supplies the transcript. Durable-owned history remains entity-local. + External and service-owned history does not require an entity-side message mirror. +- Core compaction controls model input. Eager transcript pruning and pressure eviction are separate + opt-ins, defaulting to `retention="keep_all"` and `max_state_bytes=None`. +- All entity-local slices share one size budget and commit at one operation boundary. External + writes and tool side effects are outside that transaction. +- New state writers require compatible workers and polling clients, including supported rollback + behavior for in-flight sessions and workflows. + +This ADR specifies a proposed contract for Python and .NET. The existing Python prototype uses a +combined execution/transcript layout. Its coverage and limitations are recorded in +[Prototype Evidence](#prototype-evidence), separately from the implementation requirements below. + +**Sections** + +- [Context and Terminology](#context-and-terminology) +- [Considered Options](#considered-options) +- [Ownership and State Model](#ownership-and-state-model) +- [Execution, Delivery and Session Lifecycle](#execution-delivery-and-session-lifecycle) +- [Retention Policy](#retention-policy) +- [Workflow Context](#workflow-context) +- [State Evolution and Compatibility](#state-evolution-and-compatibility) +- [Consequences](#consequences) and [Validation Requirements](#validation-requirements) +- [Dependencies and Follow-up Work](#dependencies-and-follow-up-work) +- [Prototype Evidence](#prototype-evidence) + +## Context and Terminology + +Durable agents persist conversation state across worker restarts. Durable workflows also carry +conversation context between executors in checkpointed envelopes. These create three distinct +pressures. + +| Pressure | Scope | Control | +| --- | --- | --- | +| Model context window | Input to one model call, in both core and durable execution | Core compaction | +| Token cost and latency | History sent on each call, in both runtimes | Core compaction and workflow projection | +| Persisted state capacity | Cumulative state and transport payloads in durable execution | Backend offload and explicit retention | + +Reducing model input does not necessarily reduce storage. An exclusion-and-summary strategy can +retain the original messages and add summaries, increasing stored size. A token-window setting is +therefore not a storage-byte limit. + +Durable Task Scheduler (DTS) has an unoffloaded message limit of 1 MB. The Azure Storage backend +compresses payloads above 45 KB into a `-largemessages` blob instead, but still incurs CPU, +I/O and memory costs. The design must work without offload and must distinguish a hard transport +limit from an operator-selected storage budget. + +Core's [compaction design][adr0019] provides two relevant mechanisms: + +1. **In-run filtering** projects the messages supplied to the model without deleting the originals. + It can operate on context supplied by any supported history provider. +2. **Store reduction** rewrites stored history. In the implementations evaluated for this ADR, + Python's `after_strategy` targets session-state history, while .NET exposes `IChatReducer` on + `InMemoryChatHistoryProvider`, including the `strategy.AsChatReducer()` bridge. External stores + do not share a general rewrite contract. See [Dependencies](#dependencies-and-follow-up-work). + +The original durable entity bypassed the history-provider pipeline and rebuilt a session for each +operation. The design restores that pipeline and session state rather than introducing a separate +durable-only compaction API. It must preserve user configuration, message ordering and atomic +tool-call/result and reasoning groups, while keeping deletion explicit and observable. + +| Term | Meaning in this ADR | +| --- | --- | +| History provider | Python `HistoryProvider` or .NET `ChatHistoryProvider` | +| Primary provider | A history provider with loading enabled. Additional providers may be store-only sinks. | +| Transcript | Messages and associated history/compaction metadata, distinct from execution receipts | +| `conversationHistory` | The entity's existing persisted transcript field, not a requirement for every history owner | +| Execution and delivery state | Request-level bookkeeping, original results and completion receipts | +| Service-storing client | A client whose default is service-side storage | +| Service-owned run | A run for which the model service supplies history, determined from effective options | +| `source_id` / `history_source_id` | The provider's identifier / the identifier a compaction provider uses to locate that history | +| Non-evictable floor | Serialized entity data that transcript retention cannot remove | + +The labels L1, L2 and L3 identify different integration points, not three forms of storage eviction. + +| Surface | Mechanism | Effect | +| --- | --- | --- | +| L1, agent context | Core `CompactionProvider` / `compaction_strategy` | Projects model input without deleting stored history | +| L2, eager pruning | `retention="follow_compaction"` | Opt-in deletion of excluded local transcript messages | +| L3, workflow context | `context_mode` / `context_filter` and delta transport | Selects and transports context between executors | +| Capacity safety | Optional `max_state_bytes` budget | Evicts eligible local transcript groups under pressure, independently of L2 | + +The Python prototype demonstrates the L1/L2 integration. The evaluated .NET compaction-state +representation has an additional storage constraint described in dependency 4. The target contract +does not imply that both implementations already provide every capability. ## Considered Options -- **Option 1, in-run filter only (rejected).** Reuse the agent's core compaction without changing - durable history. This bounds model input but not persisted state. -- **Option 2, bespoke pre-write compaction in the agent entity.** Add durable-specific code that - compacts `ConversationHistory` inside the entity operation before checkpoint. Rejected because it - duplicates core's store-reducer behavior. -- **Option 3, on-storage maintenance compaction (deferred).** Compact persisted history from a - separate entity operation. This may suit expensive summarization but does not prevent in-turn - growth. -- **Option 4, workflow context projection and delta transport (chosen).** Honor - `AgentExecutor.context_mode` and `context_filter`, then send each target only the unseen suffix of - that projection rather than serializing the whole `full_conversation` on every visit. -- **Option 5, auto-derive a durable store reducer (rejected as default).** Derive a lossy reducer - from a configured in-run strategy. The explicit equivalent is `follow_compaction`. Agents with - no compaction strategy can opt into independent pressure eviction with `max_state_bytes`. -- **Option 6, durable store as a `ChatHistoryProvider` (chosen).** Back the durable entity's - persisted conversation with a core `ChatHistoryProvider` implementation, so both core hooks apply - on the durable runtime from the user's unchanged configuration. The in-run filter runs in the - agent pipeline (L1), and a user-configured reducer or strategy can bound the durable store (L2, - opt-in). External history providers also rejoin the context pipeline, and the entity stops - keeping their content, so each store bounds only what it owns. -- **Option 7, offload large payloads to blob storage (backend-specific, not part of the portable - design).** Raise the ceiling instead of reducing content, using the Durable Task Scheduler [large - payload extension](https://learn.microsoft.com/azure/durable-task/scheduler/durable-task-scheduler-large-payloads). - Non-lossy, and the same technique the Azure Storage backend has always used internally. - - Deliberately **not** counted as part of the chosen design, because it is not available everywhere. - It adds an Azure Blob payload-store dependency, the Azure Storage backend already does the - equivalent internally so configuring it there is redundant, and Durable Functions Python cannot - opt in at all today (gap 6). Treat it as an optimization a specific deployment may enable, - detected rather than assumed: nothing in this design may depend on it being present. A deployment - can set an explicit byte budget, or use `max_state_bytes="backend_limit"` when its host exposes a - hard entity limit. A host that cannot identify such a limit requires an explicit number. - -## Decision Outcome - -Chosen option: **Option 6, express durable conversation storage as a core `ChatHistoryProvider`**, -combined with workflow context projection and delta transport (Option 4). The two solve different -surfaces. - -**The entity's execution and delivery contract is independent of the history provider.** Every -configuration uses the same request-level bookkeeping, original result delivery and completion -receipts. The selected history owner decides where transcript messages and compaction metadata live. -An entity-local durable transcript is one implementation of that history contract, not the entity's -execution journal. - -| Surface | Mechanism | Behavior | +1. **In-run filtering alone, rejected.** It bounds model input but leaves cumulative durable state + unbounded. +2. **Bespoke pre-write compaction in the entity, rejected.** It duplicates core strategies and + grouping rather than integrating with the history-provider abstraction. +3. **Separate on-storage maintenance, deferred.** It may suit expensive summarization, but cannot + prevent state from exceeding its limit during an active turn. +4. **Workflow projection and delta transport, selected.** Honor `AgentExecutor.context_mode` and + `context_filter`, then avoid repeatedly sending the same prefix to a target. +5. **Automatically derive a lossy store reducer, rejected as a default.** A model-input exclusion + is not implicit permission to delete. Users can opt into `follow_compaction`, or independently + set a pressure budget without configuring compaction. +6. **Durable storage as a core history provider, selected.** Reuse the core pipeline and session + state while keeping execution/delivery independent of history ownership. Each store retains its + own lifecycle policy. +7. **Large-payload offload, optional and backend-specific.** The DTS + [large-payload extension][offload] raises the ceiling without deleting content, but requires an + Azure Blob payload store and is not + available through every host. Azure Storage already offloads internally. No portable guarantee + in this ADR depends on offload being present. + +## Ownership and State Model + +### Transcript ownership + +The entity owns execution and delivery in every configuration. That state includes original results +or references, not only metadata. The history owner independently supplies and retains the +transcript. + +| History owner | Transcript location | Transcript policy | | --- | --- | --- | -| **L1, agent context** | The user's configured core `CompactionProvider` / `compaction_strategy` | Non-lossy projection of model input. The same agent configuration works durably. | -| **L2, eager store pruning** | Core compaction annotations plus `retention="follow_compaction"` | Opt-in deletion of messages the user's strategy excluded. Python only today because .NET is blocked by duplicated compaction state (gap 4). | -| **L3, workflow context** | Existing `AgentExecutor.context_mode` / `context_filter` projection plus per-target delta transport | Controls what crosses between executors without repeatedly sending the same prefix. This is not a core compaction hook. | -| **Capacity safety** | Optional `max_state_bytes` budget | Evicts oldest groups under pressure, independently of whether compaction is configured. | +| `DurableHistoryProvider` | Entity-local `conversationHistory` | Configured eager pruning and pressure eviction | +| External primary provider | Redis, Cosmos, file or its chosen store | The provider's own retention policy | +| Model service | The service | Service retention, continued through its conversation ID | +| Agent without a context pipeline | Entity-local history through legacy replay | Optional pressure eviction | + +```mermaid +flowchart TB + COMMON["Every run uses the same entity contract
Execution, delivery, session and workflow control"] + COMMON --> OWNER{"Who owns history on this run?"} + OWNER -->|"Durable provider or legacy replay"| LOCAL["Entity-local transcript
Messages, IDs and annotations"] + OWNER -->|"External provider"| EXTERNAL["Provider's store
No required entity-side message mirror"] + OWNER -->|"Model service"| SERVICE["Service transcript
Conversation ID in entity session state"] +``` + +External and service-owned turns do not require contentless copies of each request message. +Execution correlation does not require a message-level mirror, and entity-local message IDs do not +necessarily identify external-store records. Any message journal needs an explicit consumer and +lifecycle. +Required [workflow deduplication state](#workflow-context) must nevertheless survive transcript +pruning. Selecting a different owner does not implicitly discard existing local history. + +### Logical state slices + +The state has three logical slices. This separation does not require new nested JSON objects or +relocating `conversationHistory`. + +```mermaid +flowchart LR + ENTITY["One entity / one session
One total size budget"] + ENTITY --> EXEC["Execution and delivery, every owner
Request-level bookkeeping
responseMailbox + completedCorrelations"] + ENTITY --> CONTROL["Session and workflow control, as needed
session + ingestedPositions
Custom-ID deduplication bookkeeping"] + ENTITY --> HISTORY["Local transcript, when used
conversationHistory
Messages, IDs, annotations + truncation"] +``` + +History providers own transcript read, append and reconciliation behavior. The entity runtime +physically commits all entity-local slices at the operation boundary. Exactly one path must append +each transcript input and output, preserving provider storage choices without double-writing or +dropping messages. The current prototype's append ownership is described in +[Prototype Evidence](#prototype-evidence). -The common path below applies to durable, external and service-owned history. Workflow projection -and delta selection happen before the request reaches the entity. The two storage policies are -independent opt-ins and never rewrite the caller's result. +Each standalone session receives a distinct entity key. Workflow agent entities are scoped by +workflow instance and executor. Their full entity identity also supplies a stable external-provider +session key, so workflow nodes cannot accidentally share a conversation. Old sessions occupy +separate entities and do not consume a new session's capacity. + +### Provider selection + +Registration selects the history adapter without changing the execution contract or mutating the +caller's agent. Preserve `source_id` when replacing a provider so compaction configured through +`history_source_id` continues to resolve the same history. Core's default history source is +`"in_memory"`. Substitution preserves existing compaction triggers and does not enable compaction. + +| Configuration | Registration behavior | +| --- | --- | +| No load-enabled primary, including sink-only configurations | Inject durable history using core's default `source_id`. Preserve store-only sinks. | +| `InMemoryHistoryProvider` | Replace it with durable history, preserving `source_id` and `skip_excluded`. | +| Hand-configured `DurableHistoryProvider` | Preserve explicit `prune_excluded`. If unset, inherit the registration retention policy. | +| External load-enabled primary | Keep it. Do not add durable history alongside it. | +| Service-storing client without an external primary | Keep durable history available for client-owned runs and silent on service-owned runs. | +| No core context pipeline | Preserve the legacy entity-local replay path. | + +Reject more than one load-enabled primary. Additional store-only audit or evaluation sinks retain +their configured storage and lifecycle. If substitution is needed, shallow-copy the agent and its +provider list. Substitution does not import content accumulated in an in-memory provider before +registration, and that import scenario is outside the persisted-state upgrade contract. + +```mermaid +flowchart TB + PIPE{"Core context pipeline?"} + PIPE -->|"No"| LEGACY["Keep legacy entity-local replay"] + PIPE -->|"Yes"| COUNT{"Load-enabled primary providers?"} + COUNT -->|"More than one"| REJECT["Reject registration"] + COUNT -->|"None, including sink-only"| INJECT["Inject durable provider
Preserve store-only sinks"] + COUNT -->|"One"| TYPE{"Which primary?"} + TYPE -->|"In-memory"| REPLACE["Replace with durable provider
Preserve source_id and skip_excluded"] + TYPE -->|"Durable"| KEEP["Keep explicit pruning choice
Otherwise inherit retention setting"] + TYPE -->|"External"| EXTERNAL["Keep external provider
Do not add durable alongside it"] +``` + +### Per-run ownership + +Resolve `store` from run options, then agent defaults, then the client's `STORES_BY_DEFAULT`. +Durable follows core's per-run choice rather than pinning an owner for the session. An attached +durable provider yields no local history on a service-owned run and is available on client-owned +runs. These are ownership states, not retention modes. + +Keeping the durable provider available prevents core from injecting an unmanaged in-memory history +slice into persisted session state on a `store=False` run. An external primary already occupies +that role, so it needs no additional durable provider. + +Changing `store` does not migrate service history, create placeholders for missing content, or +promote mailbox responses into the transcript. Preserve the service conversation ID and effective +options. Explicit ownership migration or forking belongs in the core lifecycle follow-up. + +## Execution, Delivery and Session Lifecycle + +The execution path is the same for every history owner. Workflow projection and delta selection +occur before the entity receives the request. ```mermaid sequenceDiagram @@ -197,484 +259,220 @@ sequenceDiagram end ``` -L3 decides what crosses between workflow nodes, L1 decides what the model reads, and transcript -retention decides what history remains available. Mailbox expiry is a separate delivery policy. -External provider and service writes are not part of the entity's atomic commit. +### Result delivery and completion receipts -This gives agent-level **configuration parity**, not byte-for-byte parity in every workflow cycle. -Durable workflow nodes intentionally deduplicate repeated upstream context before persisting it. The -L3 section explains the measured difference. +Signal-based client and HTTP paths poll entity state by correlation ID. `responseMailbox` retains +the original success or runtime-error result, including response metadata, as a payload or readable +reference until a configured delivery expiry. The existing polling API cannot acknowledge receipt, +so expiry bounds payload retention. An acknowledgement operation is a possible later capability. -Capacity is handled in this order: project workflow context according to its semantics, send only -the unseen suffix to each target, raise the ceiling non-lossily where blob offload is available, -honor an explicit `follow_compaction` choice, then apply pressure eviction only when a byte budget -was configured. An exclusion normally means only "do not send this to the model". It means "delete -this" only under `follow_compaction`. +When delivery expires, remove the payload or reference but retain a `completedCorrelations` +tombstone until the entity is deleted. A duplicate correlation returns its retained result or an +already-completed status, not another agent invocation. Transcript compaction and clearing must not +alter results, remove live mailbox obligations, or erase completion receipts. Runtime-error results +and receipts are not model context. -### Who bounds what +The transcript and mailbox may share immutable payload storage, but a delivery reference cannot +depend solely on an evictable transcript entry. It must stay readable for its delivery window. +Likewise, an orchestration records the `call_entity` result for replay, independently of entity +completion records and any assistant message retained as history. -The entity always owns request-level execution and delivery state. That includes correlation and -completion information, the original response or a retrievable reference, and session/workflow -control state. These responsibilities do not move when the history provider changes. They are not -strictly metadata-only. A waiting caller needs its answer, not just evidence it once existed. +### Session restoration -Transcript ownership is the independent choice below. Each store bounds its own transcript. +Create each operation's session through the agent's own `create_session()` and restore its provider +state and service conversation ID. Carry the resulting session forward for committed successes and +errors, including pending tool approvals. The current Python serialization bridge uses +`AgentSession.to_dict()` with JSON-compatibility validation. The state bag may contain more than +metadata, and its full serialized size counts toward the entity budget. -| History owner for the run | Where the transcript lives | What bounds that transcript | -| --- | --- | --- | -| `DurableHistoryProvider` | Entity-local `conversationHistory`, with messages, IDs and annotations | Explicit eager pruning, an optional pressure budget, or the backend limit | -| Redis, Cosmos, file or custom provider | The provider's chosen store | Its own policy, such as `max_messages` or container TTL | -| Model service | The service | The service's retention | -| No context pipeline | Entity-local transcript, supplied by the legacy replay path | Optional pressure eviction or the backend limit | +Exclude the durable history provider's transient working buffer from session serialization, not +the durable transcript itself. Rebuild that buffer from persisted messages, IDs and annotations on +each turn. Reconcile compaction changes by message ID before dropping the transient slice. Core's +process-local type registry requires registering already loaded serializable types during restore. +Broad Pydantic subclass discovery is not used because of identifier-collision risk. -```mermaid -flowchart TB - COMMON["Every run uses the same entity contract
Execution, delivery, session and workflow control"] - COMMON --> OWNER{"Who owns history on this run?"} - OWNER -->|"Durable provider or legacy replay"| LOCAL["Entity-local transcript
Messages, IDs and annotations"] - OWNER -->|"External provider"| EXTERNAL["Provider's store
No required entity-side message mirror"] - OWNER -->|"Model service"| SERVICE["Service transcript
Conversation ID in entity session state"] -``` +Provider-owned versioned snapshots are the intended replacement for broad session serialization. +See [provider lifecycle dependencies](#dependencies-and-follow-up-work) for the missing contract. -For externally or service-owned turns, the common execution record does not require a contentless -copy of each request message or a locally generated ID purporting to identify a message in the -external store. Message-level journaling needs an explicit consumer and lifecycle, not a default -mirror. This does not remove response delivery obligations or the custom-ID deduplication dependency -described below. Existing local transcript data remains subject to its own retention and transition -rules. Choosing an external owner is not permission to discard it implicitly. - -Orchestrations reach the entity through `call_entity`, which returns the value directly. The -orchestrator records that task result for deterministic replay. The entity records completion and -result delivery under the same contract as a client or HTTP call. If durable owns history, an -assistant message also belongs to its transcript. These records can contain the same payload for -different lifecycles. None should be used as the other's deletion or completion signal. - -**Ownership is resolved per run, as it is in core.** Core gives an explicit `store` in the effective -run options precedence over the client's `STORES_BY_DEFAULT`, so an agent registered against a -service-storing client can still be asked to keep one turn client-side. Durable mirrors that rule -rather than pinning an owner for the session and rejecting a core-supported run option. - -A durable history provider is attached for a service-storing client **when no external primary -history provider is configured**. It claims the slot before core can inject an -`InMemoryHistoryProvider` on a `store=False` run. Persisting that injected provider with the session -grew state by 321 bytes per turn in the prototype and put the transcript outside durable retention. -An external primary already occupies that slot, so durable is not added alongside it. - -Attachment is not ownership. An attached durable provider yields no local history on a service-owned -run and is available for client-owned runs. This does not require retaining shells for messages -whose contents it never stored. Changing `store` neither imports the service transcript nor promotes -mailbox responses into history. Existing contentless records are already skipped by the prototype's -replay converters. An explicit migrate or fork operation would be a core capability, not a -durable-specific reinterpretation of `store`. - -### Response delivery and duplicate suppression - -Model context, response delivery and duplicate suppression have different lifecycles. They must not -all depend on one entry remaining in `conversationHistory`. - -For client and HTTP paths, an entity signal is one-way and the caller polls by correlation id. The -response is therefore a delivery obligation. A `responseMailbox` retains each completed response -payload, or an offloaded reference to it, under that correlation id until a configured delivery -expiry. The current polling surface only reads entity state and cannot acknowledge receipt, so the -first implementation uses bounded expiry. A future acknowledgement operation can shorten it. - -When delivery expiry passes, the mailbox obligation ends and its payload or reference can be -removed. `completedCorrelations` retains a lightweight tombstone until the entity itself is deleted. -A repeated correlation id then produces an already-completed result instead of another model call -and another set of tool side effects. Pressure eviction never removes live mailbox entries or -tombstones. They can therefore become part of the non-evictable floor and cause a capacity error -rather than permit duplicate execution. Automatic entity cleanup is tracked separately under entity -lifetime. - -The mailbox preserves the original success or runtime-error result, including its response metadata. -Compaction may annotate, summarize or remove transcript messages without changing that result. -Errors that describe a failed entity operation and completion receipts are not model context. - -The selected owner may also retain the assistant response as history. The mailbox determines -whether the caller can collect it, while that owner's retention determines whether a later model -call sees it. Immutable payload storage may be shared, but a mailbox reference must remain readable -through its delivery window even if the transcript entry is deleted. It cannot be only a pointer -into an evictable transcript. Transcript pruning or clearing never clears completion receipts. - -These delivery guarantees begin with a successful entity commit. If capacity prevents that commit, -there may be no room to persist even an error response. Surface the failure through the operation's -error channel where available and diagnostics. A signal caller polling state may instead time out, -not receive a durable capacity-error result. Do not write a success or completion receipt for an -uncommitted turn. Retrying it can repeat side effects, as described under worker failures. - -### What the entity persists - -The revised design has three logical slices. The diagram groups responsibilities, not a requirement -to introduce new nested JSON objects or move `conversationHistory` to a new field. +### Commit and failure boundaries -```mermaid -flowchart LR - ENTITY["One entity / one session
One total size budget"] - ENTITY --> EXEC["Execution and delivery, every owner
Request-level bookkeeping
responseMailbox + completedCorrelations"] - ENTITY --> CONTROL["Session and workflow control, as needed
session + ingestedPositions
Custom-ID deduplication bookkeeping"] - ENTITY --> HISTORY["Local transcript, when used
conversationHistory
Messages, IDs, annotations + truncation"] -``` +Execution, session, ingestion and local transcript changes commit together once per entity +operation. A completion receipt proves a committed outcome, not completion of every intermediate +model or tool call. A worker failure before commit leaves the previous local state intact, and a +retry may repeat those calls and side effects. The design does not checkpoint between tool calls. + +External providers and the model service have independent commits. Their writes may succeed before +the entity commits. Local slice consistency therefore does not provide a distributed transaction or +exactly-once execution of uncommitted effects. + +If capacity prevents the entity commit, even a durable error result may not fit. Report failure +through the operation error channel where available and through diagnostics. A state-polling signal +caller may time out instead. Do not persist a successful completion receipt for an uncommitted turn. + +### Service conversation errors + +For the structured `previous_response_not_found` error, retry the identical current invocation up +to three additional times within the operation, waiting 0.5, 1.0 and 1.5 seconds. Stop immediately +on a different error. If matching refusals continue, fail the turn. + +These retries can recover transient visibility failures without retaining a duplicate transcript. A +genuinely expired conversation ID cannot be recovered this way. Full-transcript recovery is not part +of the design because it requires storing a second conversation continuously. The observations and +storage trade-off are recorded in [Prototype Evidence](#prototype-evidence). -Execution and delivery have the same semantics in every configuration, not necessarily identical -bytes or a permanently populated record for every field. History providers do not own correlation -receipts or polling. A durable provider owns the read, append and reconciliation behavior for its -entity-local transcript just as an external provider owns those operations for its store. The -entity runtime remains the physical writer of all entity-local slices at the operation boundary. - -There must be exactly one append path for each transcript input and output. The prototype instead -appends in `AgentEntity`, leaving `DurableHistoryProvider.save_messages()` as a no-op. The revised -implementation must rewire that ownership without appending from both places or losing provider -storage choices. This does not require an upstream lifecycle API for existing external providers. - -`ingestedPositions` and any required custom-ID deduplication record live outside the evictable -transcript. `session` carries provider state and any service conversation ID, excluding the durable -provider's transient working buffer. `truncation` records transcript loss, not request completion. -Only eligible local transcript groups are candidates for eager pruning or pressure eviction. - -**One budget, not a budget per slice.** Measure the whole serialized entity, including delivery -payloads, completion receipts, session state, cursors and any compatibility data. Separate fields do -not remove duplicate bytes from this calculation. If protected state alone cannot fit, report -capacity failure rather than evicting delivery obligations or pretending an external store can be -trimmed by entity retention. Mailbox expiry remains an independent cleanup policy. - -Each entity holds one durable agent session. A new standalone session gets a new entity key, and a -workflow agent node is keyed by workflow instance plus executor. The 1 MB DTS limit and any -`max_state_bytes` budget therefore apply to one session, not to every conversation for an agent. -Old sessions occupy separate entities and do not reduce the budget of later sessions. How long those -abandoned entities remain is the separate entity-lifetime concern described below. - -**Why the prototype kept the combined layout.** A1 changed new writes by clearing externally owned -request content while preserving `conversationHistory` and its message envelopes. We chose it over -A2, relocating the transcript into a separate history field, to avoid a transcript-location -migration for existing entities and workflows paused across deployments. It was a compatibility -trade-off, not evidence that every empty message envelope had a delivery consumer. - -The mailbox and completion-receipt changes already require a compatible transition. The revised -target therefore separates responsibilities consistently while retaining the transcript field -where possible. Physical relocation is not a prerequisite, and neither this separation nor a new -schema version makes old worker and polling behavior compatible automatically. The state-evolution -section defines that transition. A1 remains the prototype, not the target storage contract. - -### Retention - -Deletion has two independent controls. `retention` says whether a compaction exclusion is also -permission to delete. `max_state_bytes` says whether storage pressure may delete messages that the -user did not exclude. Neither control turns the other on. +## Retention Policy + +Two independent registration controls govern transcript deletion. Application defaults may be +overridden per agent. They do not change the agent's compaction configuration or an external +provider's storage policy. | Control | Value | Behavior | | --- | --- | --- | -| `retention` | `keep_all` **(default)** | Preserve messages that compaction excluded from model input. | -| `retention` | `follow_compaction` | Delete excluded messages after every turn. With no compaction configured, this has nothing to delete. | -| `max_state_bytes` | `None` **(default)** | Do not evict under pressure. A hard backend limit can still reject a write. | -| `max_state_bytes` | `"backend_limit"` | Use the hard entity-payload limit known to the host, 1,048,576 bytes for direct DTS. Registration fails if the host cannot identify one. | +| `retention` | `keep_all` **(default)** | Do not delete merely because compaction excluded a message. | +| `retention` | `follow_compaction` | Prune excluded local messages after each turn. Without compaction there are no exclusions to prune. | +| `max_state_bytes` | `None` **(default)** | Disable pressure eviction. The backend can still reject an oversized write. | +| `max_state_bytes` | `"backend_limit"` | Use the host's known hard payload limit, 1,048,576 bytes for direct DTS. Fail registration if unresolved. | | `max_state_bytes` | positive integer | Use that explicit serialized-state budget. | -Together they make all four policies expressible. +Neither control enables the other. An explicitly pinned provider `prune_excluded` value takes +precedence over the registration retention mode. The matrix below assumes no such provider override. | | No pressure budget | Pressure budget set | | --- | --- | --- | -| `keep_all` | Never delete. | Preserve exclusions, but evict oldest groups under pressure. | +| `keep_all` | Never delete transcript messages. | Do not prune because of exclusions, but evict eligible oldest groups under pressure. | | `follow_compaction` | Delete only what the user's compaction strategy excluded. | Delete exclusions eagerly, then evict oldest groups if the remainder still crosses the high watermark. | -**Why deletion is opt-in.** Core follows the same rule for every comparable bound. +### Defaults and scope -| Core mechanism | Default | -| --- | --- | -| `InMemoryHistoryProvider` | Unbounded | -| `RedisHistoryProvider.max_messages` | `None`, unbounded | -| `compaction_strategy` | `None` | -| Context-window compaction | The user must supply `max_context_window_tokens` | - -Durable storage should not silently adopt a more destructive default. Without a pressure budget, a -write that exceeds the backend limit fails while the last successfully persisted state remains -available. The operator can then raise the limit, enable a budget, or choose `follow_compaction`. -Failure is visible and recoverable; deletion is irreversible. - -**How pressure eviction works.** After the turn is recorded and before the state is persisted, the -entity measures its serialized state. Pressure eviction runs only when `max_state_bytes` is set. -`high_watermark` and `low_watermark` default to `0.85` and `0.70`. They are configurable and must -satisfy `0 < low_watermark < high_watermark <= 1`. Below the high watermark, nothing happens. Above -it, the entity targets the low watermark using detached message copies with exclusions cleared and -`TokenBudgetComposedStrategy(strategies=[])`. Clearing exclusions makes the budget reflect what is -stored, while the empty strategy list bypasses the user's context policy and uses core's -deterministic oldest-group fallback. Atomic tool groups and the newest exchange are protected. - -Clearing happens only on the detached planning copy and does not erase stored annotations. Under -pressure, an exclusion is not immunity from capacity eviction: all otherwise eligible old groups -compete by age. `keep_all` means exclusion alone never triggers deletion, while a separately enabled -pressure budget may still evict that group. - -The non-evictable floor is calculated before anything is removed. If the floor alone exceeds the -configured limit, the turn fails with a capacity error without deleting old context. If the low -watermark is unreachable, the target is clamped upward and eviction removes only enough to get -below the high watermark where that is possible. If no target below the high watermark is -reachable, the capacity condition is reported without a futile eviction pass. This prevents a -one-token approximation from deleting every evictable message for a target the state cannot reach. - -**The budget is derived from bytes, not from text.** The constraint is a byte limit but the strategy -counts tokens, so the conversion is measured from the messages in hand: the persisted size of the -evictable messages against their token count, with everything unevictable treated as a floor the -budget cannot reach below. Deriving it from `message.text` instead would make it depend on the -*kind* of content rather than its size, and a function call has no text at all, so a tool-only -conversation would produce a budget of one token and evict everything it was allowed to touch. - -**System messages are held out of the candidate set** rather than left to core's protection. Core -skips system groups in its first fallback, but it has a second, strict fallback whose purpose is to -evict them once anchors alone exceed the budget. Relying on the first therefore holds only until the -budget is small enough to matter. Excluded from the candidates, the agent's instructions are simply -not evictable, and their bytes count toward the floor. - -**Eviction leaves durable evidence.** A `truncation` record beside the conversation holds a count and -the first and last eviction times. A log line is evidence to whoever was watching at the time and to -nobody afterwards, which is no use to a user asking later why an answer lost context. It is a counter -rather than a list of what was removed, because such a list would grow without bound in exactly the -situation retention exists to resolve. Its absence is meaningful: it says nothing has been dropped. -`truncation` records loss of model context; `completedCorrelations` records completed execution. -Neither can substitute for the other. - -The measured size is the serialized state JSON, not an estimate from message text and not transport -framing added outside the state payload. Measuring a 1 MB prototype state took about 8 ms. - -**Why not prune exclusions by default.** A default-on reducer only affects agents that configured -compaction, because nothing else marks messages excludable. It would also turn a non-lossy model -projection into irreversible storage deletion without the user choosing that policy. - -**Why not rely on blob offload alone.** It raises the ceiling roughly tenfold and does not remove it. -It is also unreachable on the Durable Functions Python path today (gap 6). Explicit pressure -retention is therefore the portable fallback a deployment can enable on every host. - -**Service-managed model context** is outside compaction scope, mirroring ADR-0019. When the model -provider owns the conversation, the client holds no history to compact. Configured entity-retention -policies still apply to the entity's own record. See "Service-managed conversations". - -**Why workflows largely come "for free."** Durable workflow agent execution -(`DurableExecutorDispatcher.ExecuteAgentAsync`) runs an agent through the same -`DurableAIAgent → AgentEntity → inner agent` path as standalone durable agents, so **L1, L2 and -optional pressure retention are inherited by workflow agent executors**. The workflow's own -`full_conversation` between executors does not pass through the agent, so it needs the separate -**L3** hook and delta transport. - -### Consequences - -- **Configuration parity.** Existing agent compaction configuration works durably without changing - the agent. Retention does not choose the current model projection. -- **Consistent execution state.** Request completion and delivery do not change when transcript - ownership changes. External and service-owned turns do not require a metadata-only message mirror. -- **Independent deletion policies.** Users can follow their own compaction exclusions without - enabling pressure eviction, or enable pressure eviction while preserving those exclusions in the - stored transcript. -- **Opt-in capacity protection.** When a pressure budget is set, it covers external providers, - service-managed agents, and agents with no context pipeline. With no budget, the backend can - reject an oversized write. A non-evictable floor can still produce a capacity error either way. -- **Delivery correctness has its own cost.** Mailbox entries and completed-correlation tombstones - cannot be pressure-evicted. They consume part of the floor so the system fails rather than - silently re-executing a completed request. -- **Compatible transition, not a field move.** Separating lifecycles requires old/new response - lookup and in-flight upgrade tests even if the transcript keeps its existing field name. -- **Independent external commits.** Local slices commit together, but external history writes can - succeed before an entity commit. Uniform bookkeeping does not create a distributed transaction. -- **Python-only eager pruning.** .NET would duplicate the transcript if it persisted current - compaction state (gap 4), so a .NET implementation could use pressure retention but not L2 yet. -- **Core workarounds.** Python must publish and reconcile a working buffer because core binds - store-side compaction to session state (gaps 1 and 2). -- **Threshold behavior.** Pressure eviction changes behavior only near the configured budget. This - is less uniform than always pruning, but it leaves unaffected conversations unchanged. - -### Validation - -**Prototype evidence (Python).** Unit tests cover provider substitution, annotation round-trips, -synthetic summary insertion and reconciliation, the original retention modes, session persistence, -and workflow projection and target-side deduplication. A retention test drives a real agent through -twenty turns against a reduced budget. Scheduler integration covers persisted annotations and -message ids, external-provider session identity, schema conformance, downstream workflow context, -and Redis as the sole owner of an external conversation. - -The prototype's `_to_message()` and `_to_replayable_message()` already skip messages with no -replayable content. The revised tests must preserve that behavior while changing storage layout. -The test named `test_request_message_ids_survive_for_deduplication` checks request presence and -roles, not ID-based repeated input. It does not prove that all metadata-only envelopes are needed. - -**Required for the revised implementation.** Not covered by the prototype yet. - -- The same execution/delivery contract across durable, external, service-owned and legacy agents, - including success, errors, polling, duplicate correlations and cold reloads. -- Immutable mailbox responses and completed-correlation receipts after annotation changes, summary - insertion, transcript pruning, delivery expiry and transcript clearing. -- Exactly one transcript append path, preserving input/output storage choices, stable message IDs, - annotations and ordering without adding an external-provider mirror. -- Independent eager-pruning and pressure controls, the `"backend_limit"` sentinel, configurable - watermarks, and whole-entity capacity checks for metadata-only and oversized-response floors. -- Per-run `store` transitions across cold reloads, with and without service-issued IDs, preserving - current input and session state without synthesizing history from delivery records. -- Per-target workflow delta transport across cycles, fan-out, fan-in and replay, plus custom-ID, - missing-ID and fully repeated context cases distinct from duplicate request delivery. -- Registration failure for more than one load-enabled history provider. -- Legacy-state reading, idempotent conversion, response availability for old/new pollers, resumed - HITL workflows and supported rollback, including a new request after rollback. Include legacy - responses partially altered by compaction, Python/.NET rewrites and unknown-data preservation. -- Failure injection around local commit and external-provider writes. Verify that uncommitted side - effects are not represented as protected completed operations and specify poller timeout behavior - when capacity prevents even a durable error response. - -**Longer-term validation.** Tracked here until the ADR is approved and follow-up issues are filed. - -- Retention crossing the real scheduler limit against a live backend, rather than a reduced budget - in process. -- The .NET realization and its compaction-state blocker (gap 4). -- Blob offload (Option 7) against a real scheduler. It remains unreachable through Durable Functions - Python 1.x and the 2.x preview (gap 6). -- Idempotency of an LLM-based reducer across simulated entity retries. - -## Cross-Cutting Design Details - -- Honor the user's configured reducer trigger. Durable registration must not change compaction - cadence. -- Reuse core grouping so tool-call/result and reasoning groups remain atomic. -- Pressure eviction is deterministic and uses the estimator tokenizer without a model call. Any - future LLM reducer must give summaries stable identities and be tested across retries. -- Response delivery and duplicate suppression do not depend on transcript retention. Pressure - eviction cannot remove a live mailbox entry or the only completed-correlation tombstone. -- No consumer of execution state may depend on the primary history provider's transcript layout. - The migration reader supplies legacy lookup behavior until the versioned transition completes. -- Commit entity-local execution, session, ingestion and transcript changes at the same operation - boundary. External stores and model/tool effects are not in that transaction. -- Registration permits exactly one load-enabled primary history provider. Additional providers are - store-only sinks. -- Workflow projection preserves `context_mode` semantics, while a replay-derived per-target cursor - removes already-sent prefixes from transport. Entity positions remain the redelivery guard. -- The durable history provider belongs in `AgentEntity`. Workflow projection belongs at the - existing `AgentExecutor.context_mode` / `context_filter` seam. - -## Core Interface Gaps for Pluggable History Providers - -Prototyping the Python `DurableHistoryProvider` surfaced places where the current contracts assume a -*session-state-backed* history provider. They are recorded here because they affect **any** provider -whose store is not session state (Cosmos, Valkey, durable), not just this one. The prototype works -around them, but the cleaner fix is upstream. - -These are scoping decisions rather than oversights, and worth reading that way. Store-rewrite -compaction reaches the one store whose lifetime core controls, and the providers core ships for -other stores bound themselves instead: `RedisHistoryProvider` takes `max_messages` and trims with -`ltrim`, and a Cosmos container has its own TTL. That is the same layering this ADR follows, each -store bounding what it owns. What is missing is not the capability but a way to *express* it through -the provider abstraction, which is what makes it a prerequisite rather than a tidy-up. - -1. **Store-side compaction is bound to session state rather than to the provider.** `CompactionProvider` - has two hooks, but only `before_strategy` works with any provider because it acts on invocation - context. `after_strategy` mutates `session.state[history_source_id]["messages"]` and assumes that - mutation rewrites storage. External providers can therefore bound model input but cannot use - core to rewrite their stores. .NET similarly exposes `IChatReducer` only on - `InMemoryChatHistoryProvider`. - - The cost today is silence rather than failure: a user who wires `after_strategy` to Redis or - Cosmos gets no annotations, no summaries, no error and no warning. Verified against core's own - `FileHistoryProvider`, whose `save_messages` begins `del state, kwargs`: the identical strategy - that produced 11 exclusions and 4 summaries under `InMemoryHistoryProvider` produced none. - - *Workaround:* the durable provider publishes a working buffer under the session-state key core - expects. *Upstream fix:* put store-rewrite compaction on the provider abstraction, and in the - meantime say something when the hook cannot reach the configured store. - -2. **`save_messages()` is append-only.** The other half of the same open question. It receives only - new messages, so changes to existing messages and inserted summaries have no path back to storage. - Confirmed against shipping code rather than argued in the abstract: `RedisHistoryProvider` - persists with `rpush`, which can express "add" and nothing else, so even a provider-level - compaction hook would have no way to say "replace this" or "drop these". - *Workaround:* the durable provider reconciles its working buffer **by `message_id`** during - `after_run`. *Upstream fix:* add an explicit replace/flush operation alongside append. - - Gaps 1 and 2 together are a **prerequisite for treating an external provider as the sole store of - a compacted conversation**, not an upstream cleanup note. Until they are closed, a customer's own - store can bound what the model reads but cannot be rewritten by the compaction they configured, - and the durable runtime can only offer that capability for conversations it holds itself. - - **The workaround against the contract it should become.** Today the durable provider publishes a - working buffer under the session-state key `after_strategy` reads, then reconciles the result back - by `message_id`. That works, but it only works because the provider is willing to impersonate - session-state storage. It is invisible to any provider that does not know the trick, it silently - does nothing for the ones core itself ships for Redis and Cosmos, and it couples us to a key whose - shape core is free to change. An additive contract on the provider, `replace_messages()` plus - `flush()` alongside the existing append, would let core drive store rewrite through the - abstraction instead, make the capability discoverable, and remove the impersonation. - - **The provider contract this should become**, stated here until follow-up issues are filed after - this ADR is approved: - - 1. Store rewrite expressed on the provider abstraction, so compaction reaches any capable store. - 2. `replace_messages()` / `flush()` with an expected version, so summaries, annotations and - deletions have a concurrency-safe path back. - 3. `clear()` / `delete_session()` so reset and lifecycle behavior belong to the store that owns - the conversation. - 4. Versioned `snapshot_state()` / `restore_state()` so a provider explicitly declares what may - survive a durable turn and how that state migrates. - 5. Core exposing its **resolved** service-versus-client history ownership. Durable currently - re-derives it from `store` and `STORES_BY_DEFAULT`, which duplicates a decision core has - already made and can drift if core changes. - 6. .NET reaching the same point, which additionally needs `MessageId` and - `AdditionalProperties` to survive `FromChatMessage` / `ToChatMessage` (gap 3). - - These are upstream capabilities, not prerequisites for the first Python implementation. The - durable provider's working-buffer reconciliation remains the bounded workaround until the - contract exists. Provider-owned snapshots replace broad session serialization only after a - provider can supply a version and migration policy; wrapping an opaque state bag in a versioned - envelope before then would imply a guarantee no provider has made. - -3. **Message-level metadata was not persisted (durable schema).** Python wrote - `extension_data` asymmetrically, so annotations disappeared on round-trip. This is fixed. The - shared schema now declares `messageId` and `extensionData` as round-trip-required, describes - `session` as runtime-discriminated, and has a conformance test. A validator would not previously - have rejected these fields because the schema permits extra properties. The defect was an - under-declared contract. - - .NET still loses `ChatMessage.AdditionalProperties` and `MessageId` in - `FromChatMessage`/`ToChatMessage`. Its `[JsonExtensionData]` property is only an overflow bucket for - unmapped JSON. `ChatMessage.MessageId` **does** exist in the pinned package and needs mapping. - Exclusions themselves live on `CompactionMessageGroup.IsExcluded`, while - `AdditionalProperties` carries the `_is_summary` marker, so mapping both fields is necessary but - not sufficient for .NET compaction parity. - -4. **.NET compaction state cannot be persisted without duplicating the transcript.** This is the - blocker behind "L2 is Python-only today". `CompactionProvider.State` is documented as living in - `AgentSession.StateBag`, holds `List`, and each group serializes its full - `ChatMessage` objects. Every run rewrites it wholesale. That leaves three unappealing choices for a - durable provider that also persists the session: - - | Choice | Consequence | - | --- | --- | - | Persist the session | The conversation is stored twice, in `ConversationHistory` and again in the state bag, so entity state roughly doubles instead of being bounded | - | Omit the provider state | Exclusions and summaries are discarded and summarization can re-run | - | Return only included messages | `CompactionMessageIndex.Update()` sees a trimmed front and rebuilds from scratch, losing the incremental state | - - None of this is inherent to the history-provider approach. It resolves if core can persist - lightweight compaction metadata keyed by `MessageId` rather than whole message copies. Until then - a .NET implementation can bound entity state through the retention path, which is independent of - `CompactionProvider`. - -5. **Provider cadence splits under per-service-call persistence.** With - `require_per_service_call_history_persistence=True`, history providers run per model call while - `CompactionProvider` remains once per run. Compaction can then annotate after the last history - flush, delaying persistence until the next flush. Only `HarnessAgent` enables this today, so the - gap is latent. - -6. **Blob offload is unreachable on the Durable Functions Python path.** Not a core gap but an - upstream gap. Version 1.x, which this package pins (`>=1.3.1,<2`), does not use the durabletask - SDK, so it has no Python-side payload-store seam. The 2.x previews (`2.0.0b1` and `2.0.0b2`, Python - 3.13+) depend on `durabletask>=1.9.0`, but `DurableFunctionsWorker` and - `DurableFunctionsClient` do not expose the base types' `payload_store` parameter. The direct - durabletask path does. *Upstream fix:* expose `payload_store` on both Functions types. - -Two more core gaps are described where they matter: the process-local state-type registry under -session persistence, and the lack of a public resolved history-ownership decision under -service-managed conversations. - -## Cross-Language State Evolution +Deletion is opt-in because a model-input exclusion should not silently become irreversible storage +loss. The core configuration examined for this ADR likewise leaves in-memory history unbounded, +defaults `RedisHistoryProvider.max_messages` and `compaction_strategy` to `None`, and requires an +explicit `max_context_window_tokens` for context-window compaction. That token setting governs L1, +not entity storage. + +Without pressure eviction, an oversized write can fail while the last committed state remains +available. Recovery requires an applicable configuration change, such as enabling pruning or using +supported offload. Raising an application budget alone does not raise a backend's hard limit. +Neither offload nor an external history provider removes the cost of entity delivery/control state. + +### Whole-entity pressure budget + +Measure the serialized entity JSON, including transcript, mailbox, completion receipts, session, +ingestion metadata and temporary compatibility copies. This excludes transport framing added +outside the state payload. Logical slices do not receive separate allowances or make duplicate +payload bytes free. + +When a pressure budget is set, evaluate it before the operation's state commit, after any enabled +eager pruning. Configurable `high_watermark=0.85` and `low_watermark=0.70` must satisfy +`0 < low_watermark < high_watermark <= 1`. Below high, do nothing. At pressure, target low using +core's deterministic oldest-group fallback via `TokenBudgetComposedStrategy(strategies=[])`. + +Plan with detached messages whose exclusion flags are cleared, leaving stored annotations intact. +All otherwise eligible old groups compete by age, including groups excluded from model context. +Preserve atomic tool-call/result and reasoning groups. Hold system messages out of the candidate +set, since core's stricter fallback can otherwise evict them. Protect the newest exchange, live +mailbox obligations, completion receipts, session state and ingestion/custom-ID control state. + +Calculate that non-evictable floor before deleting anything. If it alone exceeds the configured +limit, fail without deleting transcript history. If the low watermark is unreachable, raise the +target only enough to get below high where possible. If no target below high is reachable, report +the capacity condition without a futile eviction pass. + +The strategy accepts tokens, but its budget conversion must use persisted evictable-message bytes +and their token count after subtracting the floor. Do not derive it from `message.text`, which can +be empty for large tool payloads. Remeasure actual serialized state rather than treating the token +estimate as the storage limit. No model call is needed for pressure eviction. + +### Observable deletion + +Persist `truncation` with `evictedMessageCount`, `firstEvictedAt` and `lastEvictedAt`. Use bounded +aggregate evidence, not a growing list of removed messages. Absence means no recorded transcript +eviction. This record describes lost model context, while `completedCorrelations` describes +completed execution. Neither substitutes for the other. + +## Workflow Context + +Workflow agent nodes use the same `DurableAIAgent` to `AgentEntity` to inner-agent path as +standalone durable agents. They inherit execution, session, compaction and retention contracts. Only +inter-executor projection and transport are workflow-specific. Each node's transcript stays with its +selected history owner. + +### Projection and delta transport + +Honor `AgentExecutor.context_mode`: `full` (the default), `last_agent`, or `custom` with a +`context_filter` of type `Callable[[list[Message]], list[Message]]`. Project the upstream +`AgentExecutorResponse.full_conversation` first, then send the target's unseen positions as +`RunRequest.context_messages`. These are invocation inputs, not a +mandatory entity-local transcript mirror. + +```mermaid +flowchart TB + subgraph ORCH["Durable workflow orchestrator, re-executed every episode"] + FC["full_conversation"] + PROJ["L3: context_mode / context_filter
full, last_agent, custom"] + DELTA["Select unseen positions for this target
Replay-derived target and producer cursor"] + FC --> PROJ --> DELTA + end + + subgraph NODE["Agent node, the same execution contract as standalone"] + GUARD["ingestedPositions
Reject repeated positions"] + ENTITY["AgentEntity for this workflow node
Execution, delivery and session control"] + INNER["Inner agent + selected history owner
Configured compaction and retention apply"] + GUARD --> ENTITY --> INNER + end + + DELTA -->|"New context_messages
Stamped workflow identities"| GUARD + INNER -->|"response"| FC +``` + +Projection controls which context may reach a target. Delta transport avoids repeatedly sending the +same permitted prefix without changing that semantic choice. Entity-side deduplication occurs too +late to reduce the serialized call payload. The prototype's complete-projection measurements are in +[Prototype Evidence](#prototype-evidence). + +Workflow messages use `wf_{executor}_{position}` identities. Maintain a separate transport cursor +for each `(target, producer)` pair, reconstructed from deterministic orchestration replay rather +than checkpointed independently. Fan-out targets advance separately. Fan-in compares each message +only with its own producer's cursor, never a minimum or maximum across different producers. + +The entity persists `ingestedPositions` as the redelivery guard. Neither its position map nor +transport cursors rewind when transcript retention removes messages. If the entity is ahead of a +replay-derived cursor, it rejects repeated positions and accepts newer ones. It must not request an +evicted prefix again. This preserves configuration parity, not identical repeated-message counts in +every cycle compared with an in-process workflow. + +Custom IDs outside the workflow format are not monotonic positions. The prototype uses a +`known_ids` lookup derived from stored message envelopes. Before omitting externally owned message +records, preserve or replace that lookup in control state with an explicit identity scope and +lifetime. Test repeated context under a new correlation separately from duplicate delivery of a +completed request. Neither form of deduplication implies that local IDs match an external store. + +### Replay constraints and projection placement + +A `context_filter` must be synchronous, deterministic, side-effect-free and independent of time, +randomness or external state. It runs inside orchestration replay, potentially more than once for +a logical handoff. `full` and `last_agent` are deterministic list projections. A custom filter's +side effects can repeat, its I/O can fail a later replay, and its latency is paid on each episode. + +The evaluated Durable Task SDK compares action identity and kind, not action-input equality. A +different recomputed input can be discarded in favor of the recorded result without a +`NonDeterminismError`. This is not permission to use impure filters or a guarantee that arbitrary +user code is safe during replay. + +Projection remains in the orchestrator so only its result crosses the handoff. Target-side +projection would carry the full conversation on the wire. An activity could isolate arbitrary user +code from orchestration replay at the cost of a scheduling round trip per handoff. That alternative +and public accessors replacing `_context_mode` / `_context_filter` reads are tracked in +[#79][issue79]. + +## State Evolution and Compatibility The state schema is shared by Python and .NET, so additive JSON is not automatically a safe minor -version. The current .NET reader registers only the `request` and `response` discriminators and -throws on an unknown `$type`. The previous Python reader also throws, because its fallback still -converts `$type` through an enum that contains only those two values. Writing `errorResponse` or -`compaction` before both readers understand them would therefore break a mixed-version worker and a -rollback to the previous Python package. +version. The legacy readers evaluated during prototype development accept only `request` and +`response` entries. .NET polymorphic deserialization rejects unknown `$type` values, and Python's +fallback converts them through the same limited enum. Before emitting `errorResponse`, `compaction` +or revised delivery state, establish a compatible version floor. A major-only schema check does not +protect against unsupported minor additions. **Compatible reading includes behavior, not just JSON.** The prototype's polling paths use `try_get_agent_response()` to find responses in `conversationHistory`. A client that only preserves @@ -688,7 +486,7 @@ New entry kinds and lifecycle fields use a two-phase rollout. layouts, preserve unknown optional data, and keep unknown entry kinds out of model context. Legacy state resolves completion from recorded response entries. Revised state resolves it from the mailbox and completion receipts, distinguishing expired delivery from work not yet completed. - Workers must also maintain the revised write contract when handling an already-converted entity. + Workers must also maintain the revised write contract when handling an already-converted entity. 2. After every supported worker and response-reading client meets that reader floor, enable the revised writer. A worker cannot inspect the versions of its peers or clients, so this is a release and deployment gate, not a runtime handshake. @@ -733,367 +531,236 @@ are required. A version bump alone does not make old workers or clients compatib include paused HITL resumes, old/new polling, repeated conversion, a new request after rollback, polling after transcript pruning, Python/.NET round-trips and unknown-data preservation. -## L3 Realization: Workflow Context Parity +## Consequences + +- Agents reuse core compaction configuration. Execution/delivery semantics remain consistent across + durable, external and service-owned history, without a required external message mirror. +- Eager pruning and pressure eviction can be enabled separately. Both remain non-deleting by + default, so an unconfigured session can still reach its backend limit. +- Pruning cannot change an original result or erase completion evidence. Those protected records + consume capacity and can prevent further writes even when transcript retention is enabled. +- Pressure eviction changes available future history, not the current model projection. It operates + near the configured budget rather than deleting continuously. +- Local slices commit together, but external writes and uncommitted tool effects can repeat after + failure. This is not a cross-store transaction or exactly-once side-effect guarantee. +- The state transition requires compatible workers and polling clients even if the transcript + retains its field name. In-flight workflows and supported rollback are release requirements. +- The Python integration uses a session-buffer bridge until core exposes provider lifecycle APIs. + The evaluated .NET compaction-state format requires additional work for eager-pruning parity. + +## Validation Requirements + +The following are acceptance requirements for the proposed implementation, not claims about the +existing prototype's coverage. + +1. **Provider-independent execution.** Test success, errors, polling, repeated correlations and + cold reloads with durable, external, service-owned and legacy agents. Verify one transcript + append path, provider storage choices, stable IDs, annotation round-trips and summary ordering. +2. **Delivery lifetime.** Original mailbox responses and references must survive transcript + annotation, summary insertion, pruning and clearing. Test delivery expiry, completion receipts, + and a duplicate request after its transcript response was removed. +3. **Retention matrix.** Exercise all four combinations of eager pruning and pressure budget, + explicit provider overrides, `"backend_limit"`, custom watermarks and unresolved host limits. + Test system messages, newest exchanges, atomic tool/reasoning groups, metadata-only floors, + oversized results, unreachable targets and truncation evidence. +4. **Session continuity.** Restore provider types, pending approvals and service conversation IDs + on committed success/error paths. Test per-run `store` transitions with and without a service + ID, preserving current input and skipping contentless legacy records without importing mailbox + results into history. Exercise bounded matching-error retries and immediate failure on others. +5. **Workflow inputs.** Test cycles, fan-out, fan-in, replay, cursors and evicted prefixes. + Distinguish repeated context under a new correlation from repeated request delivery. Include + custom, missing and fully repeated message IDs, plus deterministic custom projection. +6. **Registration.** Verify no-primary and sink-only injection, in-memory replacement, preserved + `source_id`/`skip_excluded`, explicit `prune_excluded` precedence, external-provider preservation + and rejection of multiple load-enabled primaries. +7. **State transition.** Test legacy reading, idempotent conversion, old/new polling, paused + human-in-the-loop (HITL) resumes and new requests after rollback. Include partially altered + legacy results, expiry grace, Python/.NET rewrites and unknown-data preservation. +8. **Failure boundaries.** Inject failures around local commit and external writes. Uncommitted + effects must not become protected completed operations. Verify the polling timeout/error + behavior when capacity prevents even an error-response commit. + +Live scheduler-limit tests, offload validation and cross-language compaction parity remain required +as those capabilities are implemented. Any future LLM-based reducer also needs stable summary +identities and retry/idempotency tests. Reduced-budget prototype tests do not substitute for these. + +## Dependencies and Follow-up Work + +The constraints below describe the implementations evaluated during prototype development, not an +assertion that later package versions retain every limitation. Revalidate each dependency against +the versions selected for its implementation PR. New follow-up issues will be filed after ADR +approval. + +### 1. Provider-owned store reduction + +The evaluated Python `CompactionProvider.after_strategy` mutates +`session.state[history_source_id]["messages"]`, unlike `before_strategy`, which acts on invocation +context. An external provider can therefore supply input for L1 without exposing its store to L2. +.NET similarly attaches `IChatReducer` to `InMemoryChatHistoryProvider` rather than all stores. + +The Python durable provider bridges this by publishing a transient session-state working buffer +and reconciling its messages by message ID into entity state during `after_run`. This dependency +must be isolated and tested. It does not give Redis, Cosmos, file or other providers a general +rewrite capability. Core should expose store-rewrite capabilities and diagnose a configured hook +that cannot reach its store. + +### 2. Append, lifecycle and snapshot capabilities + +`save_messages()` receives new messages rather than a replacement transcript. In the evaluated Redis +provider, `rpush` appends content and `max_messages`/`ltrim` bounds it independently. A Cosmos +container can use TTL. Neither mechanism is a core compaction rewrite contract. + +The upstream provider contract needs: + +- Discoverable store reduction and `replace_messages()` / `flush()` with an expected version. +- `clear()` / `delete_session()` owned by the provider's lifecycle policy. +- Versioned `snapshot_state()` / `restore_state()` with provider-defined payloads and migration. +- Core's resolved service-versus-client ownership decision, avoiding drift from durable's duplicate + option-precedence logic. +- Equivalent .NET capabilities, including the message metadata described in dependency 3. + +Dependencies 1 and 2 gate general provider-owned compaction/lifecycle parity. They do not gate the +initial Python durable bridge, logical state separation or use of an external provider's existing +API. Versioned `{provider, version, payload}` snapshots must wait for a real provider version and +migration policy. Until then, retain the documented session serialization bridge. Explicit owner +migration/forking is also a core follow-up, not an interpretation imposed on `store` by durable. + +### 3. Message metadata across runtimes + +Message identity and annotations must round-trip through durable state. The Python prototype +includes the `messageId` and `extensionData` mappings and schema conformance coverage. Unknown-field +tolerance alone did not ensure those fields were preserved by conversion code. + +The evaluated .NET `FromChatMessage` / `ToChatMessage` path loses `MessageId` and +`AdditionalProperties`. `[JsonExtensionData]` preserves otherwise unmapped JSON but does not supply +those mappings. The pinned `ChatMessage` exposes `MessageId`. .NET exclusions live on +`CompactionMessageGroup.IsExcluded`, while `_is_summary` is message metadata, so mapping these +fields is necessary but not sufficient for full compaction parity. + +### 4. .NET compaction-state representation + +The evaluated `CompactionProvider.State` stores `List` with complete +`ChatMessage` copies in `AgentSession.StateBag`. Persisting it alongside a durable transcript +duplicates messages. Omitting it loses exclusions and incremental summarization state. Returning +only included messages can cause `CompactionMessageIndex.Update()` to rebuild that state. + +Lightweight compaction metadata keyed by `MessageId` is the desired upstream representation. Until +that gap is resolved, pressure retention can operate independently, but Python's eager-pruning +integration does not establish .NET parity. + +### 5. Provider callback cadence + +With `require_per_service_call_history_persistence=True`, history providers run per model call +while compaction remains once per run. Annotations made after the final history flush can therefore +be persisted later than intended. The evaluated implementation enables this on `HarnessAgent`. +Preserve the configured cadence and test the final flush ordering. + +### 6. Host payload-store access + +The Functions Python dependency evaluated here is `>=1.3.1,<2`, without SDK payload-store access. +The inspected `2.0.0b1`/`2.0.0b2` previews require Python 3.13+ and `durabletask>=1.9.0`, but +their `DurableFunctionsWorker` and `DurableFunctionsClient` do not expose the base `payload_store` +parameter. The direct durabletask path does. Exposing that parameter remains a host dependency. + +Backend metadata for `max_state_bytes="backend_limit"` is also needed where the host cannot identify +a hard limit. Do not silently infer an unlimited or offloaded budget in that case. An explicit byte +budget remains the portable option. + +### Release gates and excluded scope + +- State and response-consumer compatibility must precede revised writes, following + [State Evolution and Compatibility](#state-evolution-and-compatibility). +- Moving arbitrary custom projection into an activity and exposing public context accessors are + tracked in [#79][issue79]. The purity contract remains in force meanwhile. +- Idle TTL and abandoned-session cleanup are separate from bounding an active session, whose + interactions extend its lifetime. Cross-language cleanup parity remains tracked in [#10][issue10]. +- Broad provider snapshot/restore capabilities and owner migration are follow-ups, not additional + requirements on every external provider for this first implementation. -A workflow adds one hop in front of the agent path and changes nothing behind it. +## Prototype Evidence -```mermaid -flowchart TB - subgraph ORCH["Durable workflow orchestrator, re-executed every episode"] - FC["full_conversation"] - PROJ["L3: context_mode / context_filter
full, last_agent, custom"] - DELTA["Select unseen positions for this target
Replay-derived target and producer cursor"] - FC --> PROJ --> DELTA - end - - subgraph NODE["Agent node, the same execution contract as standalone"] - GUARD["ingestedPositions
Reject repeated positions"] - ENTITY["AgentEntity for this workflow node
Execution, delivery and session control"] - INNER["Inner agent + selected history owner
Configured compaction and retention apply"] - GUARD --> ENTITY --> INNER - end +The implementation reference is [Python prototype PR #59][prototype], at `c4582a1`. The observations +below were recorded during its development. They illustrate design trade-offs, not guaranteed size +ratios, performance targets or validation of the revised execution/delivery layout. - DELTA -->|"New context_messages
Stamped workflow identities"| GUARD - INNER -->|"response"| FC -``` +### Implementation status and compatibility trade-off -Because a node runs the same `DurableAIAgent` to `AgentEntity` to inner agent path as a standalone -durable agent, everything in the first diagram still applies inside it. Only the projection and the -delta transport are workflow-specific. Each node has an entity identity scoped to workflow instance -and executor. Its transcript remains with its selected history owner, rather than becoming another -entity-local copy when that owner is external. Session identity and ingestion state survive restarts -independently of the workflow envelope. - -In-process workflows give a downstream `AgentExecutor` the upstream conversation through -`AgentExecutorResponse.full_conversation`, governed by `context_mode` (`full` | `last_agent` | -`custom` + `context_filter`). The durable orchestrator previously flattened that to the **last -message's text**, so a downstream agent lost everything earlier nodes produced. - -Agent-level compaction needs no workflow-specific work: `AgentExecutor` passes its own session to -`agent.run()`, so the agent's `CompactionProvider` runs normally. Inter-executor context has no core -compaction hook. Durable instead honors the existing `context_mode` and invokes `context_filter` for -`custom` mode, then sends the projection's delta as `RunRequest.context_messages`. The received -messages are invocation input, visible to supported agent-level compaction. Their transcript storage -follows the selected owner, not a universal request-message journal in the entity. - -### `context_filter` must be pure under durable - -Core types the filter as `Callable[[list[Message]], list[Message]]` and requires nothing more, -because an in-process executor runs it exactly once. A durable orchestrator does not resume, it -**re-executes from the top** on every episode, returning recorded results for work already done. The -projection is computed in that re-executed code, so the filter runs again on every replay: roughly -once per node in a sequential workflow, and again each time a workflow parked on a human decision -wakes up. - -**The durable contract is therefore stricter than core's.** A `context_filter` must be synchronous, -deterministic, free of side effects, and independent of wall-clock time, randomness, and any -external state. `full` and `last_agent` satisfy this by construction, since they are list slicing. -Only `custom` can violate it. - -Violating it fails **softly**, which is worth stating precisely so the risk is neither overstated -nor dismissed. The Durable Task worker detects non-determinism by checking that an action exists at -the expected id and is of the expected kind; it never compares the action's input. The projection is -only ever an input, and nothing branches on it. So a filter that returns something different on -replay does not raise `NonDeterminismError` and does not deliver altered context to an agent. The -recomputed value is discarded and the recorded result stands. - -What does bite: - -- A filter with side effects performs them again on every replay, so one logical handoff can write - many audit entries. -- A filter that performs I/O can raise on a later replay, failing an orchestration whose original - run succeeded and whose result is already recorded. -- A slow filter is paid for on every episode rather than once. - -**Why the filter runs there at all.** Someone has to apply the projection, and the placement is a -trade. Applying it in the orchestrator keeps only the projection on the wire, which is what makes -`context_mode` an effective capacity lever. Applying it at the destination would keep user code out -of replayed territory but put the whole conversation back on the wire. Applying it inside an -activity would achieve both at the cost of a scheduling round trip per handoff. The current design -takes the first, and the contract above is the price. Revisiting that, along with replacing the -private `_context_mode` / `_context_filter` reads with a public accessor, is tracked in -[#79](https://github.com/microsoft/agent-framework-durable-extension/issues/79). - -Projection and transport are separate. After applying `context_mode` or `context_filter`, the -orchestrator sends each target only positions it has not sent to that target before. The cursor is -keyed by target and producing executor, because fan-out targets advance independently and fan-in -combines positions from several producers. Messages remain stamped as -`wf_{executor}_{position}`. - -Each message is compared only with the cursor for its own `(target, producer)` pair. Fan-in does not -take a minimum or maximum across producers: positions from two branches are independent even when -the numeric indexes happen to match. A cursor at 20 means the next delta for that pair begins after -20; it never requests positions that the target later evicted from its transcript. - -The cursor is derived rather than checkpointed. A durable orchestrator re-executes the same message -sequence from the top on every episode, so a local cursor map is reconstructed deterministically -before any recorded task result is reused. The entity still persists its highest ingested position -per producer. That is no longer the primary transport mechanism; it is the safety net that rejects -an at-least-once redelivery of a delta. Pressure retention never removes that position map. If the -entity is already ahead of a replay-derived transport cursor, it drops the repeated positions and -accepts only newer ones; neither side asks for an evicted prefix to be sent again. - -**Custom message IDs need an explicit deduplication record.** The prototype handles IDs outside the -`wf_{executor}_{position}` format through a `known_ids` lookup built from stored message envelopes. -Removing those envelopes changes that fallback. The revised implementation must preserve or replace -this lookup in workflow/control state before omitting externally owned message records. Specify its -identity scope and retention independently of transcript compaction, and test repeated custom input -with a new request correlation as well as redelivery of an already-completed request. They are -different forms of deduplication. No entity-local ID is assumed to identify a message in an external -store, and arbitrary custom IDs cannot be treated as monotonic workflow positions. - -### Projection and delta transport bound different costs - -`context_mode` is a semantic choice about what a target may see. Delta transport is a capacity -mechanism that avoids serializing the same allowed prefix repeatedly. The prototype measured each -complete projection before target-side deduplication as the conversation lengthened: - -| Turns | `full` (default) | `last_agent` | `custom`, last 4 messages | -| ---: | ---: | ---: | ---: | -| 10 | 8,370 | 837 | 1,674 | -| 50 | 42,010 | 841 | 1,682 | -| 200 | 168,560 | 845 | 1,690 | -| 800 | 675,560 | 845 | 1,690 | +The prototype retains the combined `conversationHistory` representation. `AgentEntity` appends +requests and responses, and `DurableHistoryProvider.save_messages()` is a no-op. External and +service-owned request content is cleared after invocation, leaving metadata-only message records. +Its replay converters already skip records with no replayable content. -At 800 turns the complete `full` projection is 64.4% of the 1 MB limit while `last_agent` is 0.1%. -That result explains why projection alone is not a general transport bound: `full` is valid when a -target needs the complete conversation, yet repeatedly sending its prefix remains linear. Delta -transport keeps that semantic choice while sending only the newly visible suffix on each visit. -`last_agent` and fixed-window `custom` projections remain useful because they also bound what the -target is allowed to read, not merely how repeated context is transported. +Retaining that layout avoided relocating transcripts when new workers resumed existing sessions or +paused workflows. The proposed contract separates execution and history ownership without requiring +that relocation. Mailbox and receipt changes still need the state and response-lookup transition +specified above. Empty per-message records are not a universal execution requirement, although the +prototype's custom-ID deduplication fallback consumes some retained IDs. + +The prototype demonstrates provider substitution, ID/annotation round-trips, synthetic summary +insertion, reconciliation, session persistence, workflow projection and target-side deduplication. +Its retention tests cover the original `keep_all`, `auto` and `follow_compaction` modes, not the +independent controls specified here. Twenty-turn tests use a reduced budget with `keep_all` as the +control. Scheduler integration covers persisted metadata, external-provider session identity, +schema conformance, downstream workflow context and a Redis-owned conversation. This does not +validate the proposed mailbox/receipt layout or source-side delta transport. -Stored-id comparison at the entity remains insufficient. Retention can remove old ids, after which -a redelivered prefix would look new and be re-ingested. The small position map survives deletion and -rejects that redelivery. Once content is evicted, the node no longer sees it; accepting the old -position again would defeat retention. +### Recorded observations -## Zero-Configuration Registration +| Scenario | Observation | Design implication | +| --- | --- | --- | +| Six-turn durable conversation with exclusions and appended summaries | 3,422 bytes without compaction, 10,177 with retained originals/summaries, 2,296 with `follow_compaction` | Model-input compaction alone can increase stored state | +| Same strategy with in-memory history | 809 to 1,087 bytes | The growth is not specific to durability, but a backend limit changes its consequence | +| Service-storing client with per-run `store=False` and no durable provider | Persisted session slice grew about 321 bytes per turn | Provider injection must follow possible run options, not only client defaults | +| Store-side strategy with in-memory versus file history | 11 exclusions and 4 summaries with in-memory history, none with the evaluated `FileHistoryProvider` | Session-buffer mutation does not rewrite an arbitrary external store | +| Serialization of a 1 MB prototype state | Approximately 8 ms in the development measurement | Measure overhead during implementation validation, not as a latency guarantee | -Registration must not require edits to an agent that already works in core. The entity therefore -substitutes history at construction time. It shallow-copies the agent when substitution is needed, -so the caller's instance remains unchanged. +The state-size observations used the prototype's combined layout. Mailbox payloads, receipts and +transition data will change that accounting. The recorded timings do not specify a portable hardware +baseline, and are not release acceptance thresholds. -These rules select a history adapter, not a different execution-state layout. An external primary -already supplies the history-provider role. No durable provider is added merely to record execution -metadata, since the entity owns that responsibility directly in every configuration. +### Complete workflow projection sizes -| User configured | Durable behavior | -| --- | --- | -| No load-enabled primary, including configurations with only store-only sinks | Inject a durable history provider using core's default `source_id`. Preserve the sinks. No compaction by default (same as core). | -| `InMemoryHistoryProvider` (± compaction) | Replace with the durable provider, **preserving `source_id` and `skip_excluded`** so any attached `CompactionProvider` keeps working untouched. | -| `DurableHistoryProvider` wired by hand | Keep it. Rebuild it with the retention mode's pruning only when `prune_excluded` was left unset, since an unset value is the absence of an opinion rather than a decision. A pinned value wins over the mode. | -| Cosmos / Redis / file / custom load-enabled primary | **Leave alone.** The user chose the store, so do not add a second primary. Execution recording remains the entity's job. | -| Service-storing client without an external primary | **Keep a durable provider available.** It pre-empts core's in-memory injection on client-owned runs and is silent on service-owned runs. Attachment does not require a local transcript for those service-owned turns. | -| Agent without the core context pipeline | **Leave alone.** Falls back to replaying persisted history. | +These are serialized bytes for complete projections before target-side deduplication, not measured +delta-transport results. -What that looks like as a single decision, taken once at registration. - -```mermaid -flowchart TB - PIPE{"Core context pipeline?"} - PIPE -->|"No"| LEGACY["Keep legacy entity-local replay"] - PIPE -->|"Yes"| COUNT{"Load-enabled primary providers?"} - COUNT -->|"More than one"| REJECT["Reject registration"] - COUNT -->|"None, including sink-only"| INJECT["Inject durable provider
Preserve store-only sinks"] - COUNT -->|"One"| TYPE{"Which primary?"} - TYPE -->|"In-memory"| REPLACE["Replace with durable provider
Preserve source_id and skip_excluded"] - TYPE -->|"Durable"| KEEP["Keep explicit pruning choice
Otherwise inherit retention setting"] - TYPE -->|"External"| EXTERNAL["Keep external provider
Do not add durable alongside it"] -``` +| Turns | `full` | `last_agent` | `custom`, last 4 messages | +| ---: | ---: | ---: | ---: | +| 10 | 8,370 | 837 | 1,674 | +| 50 | 42,010 | 841 | 1,682 | +| 200 | 168,560 | 845 | 1,690 | +| 800 | 675,560 | 845 | 1,690 | -Substitution is a registration-time decision, but **who serves history is a per-run one**. For a -service-storing client without an external primary, an attached durable provider can be active or -bypassed for history. These are not retention modes and do not change the common execution record. -An external provider is left in place instead, not supplemented with a second primary. - -Preserving `source_id` is the load-bearing detail. `CompactionProvider` locates history through -`history_source_id` (default `"in_memory"`), so a provider swapped in under the same id is invisible -to the rest of the configuration. - -Registration permits exactly one load-enabled primary history provider. A second load-enabled -provider would duplicate model context and could persist another transcript outside the primary -owner's retention, so registration rejects it rather than choosing the first silently. Additional -store-only audit or evaluation providers remain valid and keep the storage and lifecycle policy the -user configured for them. - -**Substitution changes where history is kept, and does not move what is already there.** Replacing an -`InMemoryHistoryProvider` hands ownership of the conversation to durable entity state from that point -on. Anything the caller had already accumulated in that provider stays where it is and is not copied -across, so the durable conversation begins empty. In practice this is invisible, because an in-memory -provider's contents do not survive the process that registered the agent, and registration happens -before any turn is taken. It would be visible to a caller who populated a provider in-process and then -registered the same instance with a worker, which is worth knowing but is not a supported pattern. -Importing that in-memory content is not part of the persisted-state upgrade contract. Existing -durable entity state still requires the compatible transition described above. - -### Entity Context Ownership - -1. **Who supplies conversation context?** If the agent exposes core's context-provider pipeline, - the providers do, so the entity passes a session and delivers **only the new messages**. This - holds whether history lives in durable state, an external store, or the model service. -2. **Who records execution and delivers results?** The entity, using the same correlation, outcome - and receipt contract in every case. That contract does not require a record of every externally - owned message. Necessary workflow/custom-ID bookkeeping has its own lifetime. -3. **Who bounds storage?** The deployment selects eager pruning and pressure controls for eligible - entity-local history. The budget counts all local slices. An external provider's policy remains - authoritative for its transcript and is not changed by these controls. - -The entity therefore replays its own persisted history in exactly one case, an agent that does not -expose the context pipeline. Passing a session re-engages external providers and core's in-run -filter. It does not let core rewrite an external store (gap 1). The session id is derived from the -full entity identity, name plus key, so workflow nodes cannot share an external-provider key. - -### What survives a worker failure, and what does not - -Durability here is **per operation**, not per step within one. An entity operation records the -request, invokes the agent and stages its outcome. Entity-local execution, session, ingestion and -transcript changes commit together at the operation boundary. A worker lost before that commit -leaves the previous local state intact and the operation may be retried from its start. A completion -receipt is evidence only of a committed outcome, not of every step attempted within an operation. - -What that does not give is exactly-once execution of the side effects inside a turn. A tool call -that has already run, or a model call already billed, will run again on retry. This is the same -guarantee an activity gives in Durable Task and it is not weakened here, but it is worth stating -because a reader could reasonably assume that "durable" means checkpointing between tool calls. It -does not, and an agent whose tools are not idempotent should say so through the usual mechanisms -rather than expecting the entity to protect it. - -External history providers have their own commit boundaries. Redis, a custom store or the model -service may accept a write before the entity commits. A retry can therefore repeat an external write -or a tool side effect even though local slices are consistent. The common execution contract does -not provide a cross-store transaction. Completion receipts suppress repeats of committed requests, -not side effects from interrupted, uncommitted turns. This limitation applies to external providers -as well as service-managed history. - -### A conversation id the service refuses - -A service that stores conversations can hand back an id it will not accept on the next turn. -Measured against Azure OpenAI, a streamed response reports its id in the completion event before -that response is readable: around half of streamed turns were refused this way when measured, -against none of the non-streamed ones. The id is genuine and was captured correctly, it simply -resolves a moment later. Azure has since treated this as a service defect, and re-measuring found -the chaining path fixed while `responses.retrieve` still lags. - -The entity therefore re-sends the identical request up to three times inside the same operation, -waiting 0.5, 1.0 and 1.5 seconds before the attempts. That recovers the case above without a second -transcript or an unbounded retry loop. A different error escapes immediately, and exhausting the -three matching refusals fails the turn. - -An id that has **genuinely expired** produces the same error and cannot be recovered that way, so -those turns fail, as they do in core. The alternative would be to resend our own transcript, which -only works if the entity keeps a full second copy of every conversation the service is already -holding, on every turn, against the chance of needing it. Measured over eight turns that roughly -doubled what a service-backed agent stored. Paying that continuously to insure a rare case is the -wrong trade, so it is not made. If the case proves to matter, it returns as an explicit opt-in -rather than a silent cost. - -### The session is persisted, not just its conversation id - -Providers use session state for data that must survive turns, including pending approvals. Because -the entity creates a session per operation, the current Python compatibility mechanism persists -`AgentSession.to_dict()`. It carries identifiers and a provider state bag, not necessarily small or -metadata-only. Logical slices do not remove message copies a provider keeps in -that bag. The full payload counts toward the entity budget, and the hosting runtime owns its shape. -Two details avoid an unnecessary local transcript copy: - -- The service-issued conversation id needs no bespoke field of its own - it is already part of - `AgentSession.to_dict()`. -- The durable history provider's transient session slice is **excluded** before persisting. It is - derived from the local transcript, so storing it would duplicate that transcript. This is not the - durable transcript slice itself. - -The excluded slice is a transient working buffer, not the persisted compaction record. On each -turn, `DurableHistoryProvider.get_messages()` rebuilds it from `conversationHistory`, including the -message ids and annotations already written there. `CompactionProvider.after_strategy` mutates that -buffer, and the durable provider reconciles those mutations back by message id before session -serialization drops the slice. The next turn therefore reconstructs the same working view without -storing the transcript twice. - -Restore applies the stored state onto a session created by the agent's own `create_session()`, so -the agent's session type is preserved. Core's state-type registry is process-local, so the entity -pre-registers serializable types already loaded in the process before restore. Pydantic state remains -a core gap because broad subclass discovery would be collision-prone. - -Provider-owned, versioned snapshots are the intended contract. Each provider should decide what may -cross a durable turn and how its payload migrates. Core does not yet expose a provider version or a -snapshot / restore capability, so the first implementation keeps the JSON-compatibility check and -excludes the durable history slice from broad session serialization. The provider lifecycle work -described under core gaps must land before a `{provider, version, payload}` envelope can carry a -real guarantee. - -### Service-managed conversations - -When the model service stores the conversation, it identifies the thread with an id. The entity -creates a fresh session per operation, so that id is **persisted in durable state and restored on -the next turn** as part of the serialized session. Without it, every turn would start a new thread. - -Whether the service owns history is decided with **core's precedence, not the client class alone**. -Resolve `store` from run options, then agent defaults, then the client's `STORES_BY_DEFAULT`. -Without a configured primary provider, a run using `store=False` could otherwise cause core to -inject an in-memory history slice into session state. Keeping the durable provider available avoids -that separate transcript outside local transcript retention. An external primary already prevents -that injection, so it needs no additional durable provider. - -Core permits that choice to change between runs. Durable preserves the service conversation ID and -honors effective options, but does not import service content, replay mailbox results as history, -or create a metadata-only transcript to represent missing content. The existing Python replay -converters already skip contentless legacy records. The bounded service-ID retry described above -uses the current invocation, not a mirror of past request messages. - -Core resolves this rule inside `Agent._run` and does not expose the result, so this layer -**re-derives it** and can drift if core changes. *Upstream fix:* expose the resolved decision. The -integration sample covers `store=False` against a store-by-default client. - -### Retention is a deployment policy, not agent configuration - -Compaction annotates, it does not delete. Deletion is configured at **registration** (an app-level -default with a per-agent override) rather than on the agent, so the agent definition stays portable: -the same agent runs in-memory where retention has no meaning. `retention="follow_compaction"` treats -a compaction exclusion as permission to delete. A separate `max_state_bytes` setting enables -pressure eviction and chooses its budget without changing how compaction exclusions are treated. -Both default to non-deleting behavior. - -## Follow-up Work After Approval - -This ADR records the work now so review can settle its scope. New issues will be filed after the -decision is approved. - -- **Provider lifecycle contract, upstream core.** Add concurrency-safe replace/flush, - clear/delete, resolved ownership, and versioned snapshot/restore. Core owns the abstraction and no - shared provider-version/snapshot contract is exposed today. This does not block the logical - execution/transcript separation or change an existing external provider's storage API. -- **Provider-owned session snapshots, after that contract.** A `{provider, version, payload}` - envelope has no real version or migration policy until providers supply one. -- **Explicit history-owner migrate/fork, upstream core.** `store` is a core per-run option. Durable - should not reinterpret or reject it on its own. -- **Backend metadata for `max_state_bytes="backend_limit"`, where unavailable.** Direct DTS has a - known 1 MB limit. Azure Storage has blob offload, and some hosting layers do not expose the active - backend or a hard limit. -- **State and response-consumer compatibility, before revised writes.** Ship dual-layout workers - and polling clients, idempotent legacy conversion and the supported rollback contract. Retain - transcript location where possible. Verify in-flight/HITL resumes and bidirectional state tests - before enabling writers, not as a post-release cleanup. -- **Move arbitrary `context_filter` execution out of orchestrator replay.** Existing issue - [#79](https://github.com/microsoft/agent-framework-durable-extension/issues/79) tracks using an - activity, which avoids replaying user I/O and side effects at the cost of a scheduling round trip. - -## Out of Scope: Entity Lifetime - -Idle TTL and cleanup bound how many abandoned entities remain. They do not bound an actively used -entity because each interaction extends its lifetime. Cross-language TTL parity is tracked in -[#10](https://github.com/microsoft/agent-framework-durable-extension/issues/10) and remains a -separate decision. - -## More Information - -- Parent tracking: [#4, automatic compaction to stay within durable backend limits](https://github.com/microsoft/agent-framework-durable-extension/issues/4) - and [#5, external durable-agent conversation storage](https://github.com/microsoft/agent-framework-durable-extension/issues/5). -- Builds on [ADR-0019](https://github.com/microsoft/agent-framework/blob/main/docs/decisions/0019-python-context-compaction-strategy.md) (context compaction strategy), - which defines the in-run / pre-write / on-existing-storage compaction points and the atomic-group - constraint. -- Core reference mechanisms reused: `CompactionProvider` (in-run filter), `InMemoryChatHistoryProvider` - + `IChatReducer` (store reducer), `strategy.AsChatReducer()` bridge, and the existing external - `ChatHistoryProvider` implementations (`CosmosChatHistoryProvider`, `ValkeyChatHistoryProvider`). -- Relevant durable code: `AgentEntity` and `DurableAgentState` (durable agents), - `DurableExecutorDispatcher.ExecuteAgentAsync` (durable workflow agent execution), and - `AgentExecutor` (`context_mode` / `context_filter`, `full_conversation`). +At 800 turns the complete `full` projection was 64.4% of the 1 MB limit and `last_agent` about 0.1%. +Reducing context can help when workflow semantics allow it, but is not a general replacement for +avoiding repeated prefixes. The delta implementation requires its own tests and measurements. + +### Service conversation visibility + +Early Azure OpenAI streaming probes observed returned response IDs that were not immediately +readable, affecting roughly half of sampled streamed responses versus none of the non-streamed +ones. Subsequent development probes found chaining working while `responses.retrieve` still lagged. +These are observations of the service during development, not a claim that the original failure +persists in every deployment. + +Retaining both sides locally for full-transcript recovery roughly doubled stored state in an +eight-turn service-backed comparison. The prototype removed that fallback and retained bounded +same-request retry. Expired IDs remain failures, consistent with the decision not to maintain a +second transcript as automatic recovery insurance. + +## References + +- [ADR-0019, core context compaction][adr0019] +- [DTS large-payload extension][offload] +- [#4, compaction within durable backend limits][issue4] +- [#5, external durable-agent conversation storage][issue5] +- [#10, automatic session cleanup][issue10] +- [#79, workflow context-filter replay][issue79] +- [Python prototype PR #59][prototype] + +[adr0019]: https://github.com/microsoft/agent-framework/blob/main/docs/decisions/0019-python-context-compaction-strategy.md +[offload]: https://learn.microsoft.com/azure/durable-task/scheduler/durable-task-scheduler-large-payloads +[issue4]: https://github.com/microsoft/agent-framework-durable-extension/issues/4 +[issue5]: https://github.com/microsoft/agent-framework-durable-extension/issues/5 +[issue10]: https://github.com/microsoft/agent-framework-durable-extension/issues/10 +[issue79]: https://github.com/microsoft/agent-framework-durable-extension/issues/79 +[prototype]: https://github.com/microsoft/agent-framework-durable-extension/pull/59 From d8f6582e05e591a1ea62d83974776dd5df10a1e6 Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Tue, 8 Sep 2026 16:46:53 -0500 Subject: [PATCH 09/13] docs: address ADR 0032 follow-up requirements --- .../0032-durable-thread-compaction.md | 170 +++++++++++++----- 1 file changed, 130 insertions(+), 40 deletions(-) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index fef9766..33d9191 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -19,6 +19,8 @@ delivery independent of transcript ownership. suppression for every history configuration. - The selected history owner supplies the transcript. Durable-owned history remains entity-local. External and service-owned history does not require an entity-side message mirror. +- Workflow delta transport preserves custom selection. Position monotonicity is a cursor + optimization condition, not a requirement on custom filters. - Core compaction controls model input. Eager transcript pruning and pressure eviction are separate opt-ins, defaulting to `retention="keep_all"` and `max_state_bytes=None`. - All entity-local slices share one size budget and commit at one operation boundary. External @@ -112,7 +114,7 @@ does not imply that both implementations already provide every capability. 3. **Separate on-storage maintenance, deferred.** It may suit expensive summarization, but cannot prevent state from exceeding its limit during an active turn. 4. **Workflow projection and delta transport, selected.** Honor `AgentExecutor.context_mode` and - `context_filter`, then avoid repeatedly sending the same prefix to a target. + `context_filter`, then avoid resending already-delivered messages to a target. 5. **Automatically derive a lossy store reducer, rejected as a default.** A model-input exclusion is not implicit permission to delete. Users can opt into `follow_compaction`, or independently set a pressure budget without configuring compaction. @@ -165,15 +167,16 @@ relocating `conversationHistory`. flowchart LR ENTITY["One entity / one session
One total size budget"] ENTITY --> EXEC["Execution and delivery, every owner
Request-level bookkeeping
responseMailbox + completedCorrelations"] - ENTITY --> CONTROL["Session and workflow control, as needed
session + ingestedPositions
Custom-ID deduplication bookkeeping"] + ENTITY --> CONTROL["Session and workflow control, as needed
session + ingestion receipts
Custom-ID deduplication bookkeeping"] ENTITY --> HISTORY["Local transcript, when used
conversationHistory
Messages, IDs, annotations + truncation"] ``` History providers own transcript read, append and reconciliation behavior. The entity runtime -physically commits all entity-local slices at the operation boundary. Exactly one path must append -each transcript input and output, preserving provider storage choices without double-writing or -dropping messages. The current prototype's append ownership is described in -[Prototype Evidence](#prototype-evidence). +physically commits all entity-local slices at the operation boundary. One owner must append each +transcript input and output, preserving provider storage choices without competing writers. This +does not guarantee a single external append across retries; see +[failure boundaries](#commit-and-failure-boundaries). The prototype's append ownership is described +in [Prototype Evidence](#prototype-evidence). Each standalone session receives a distinct entity key. Workflow agent entities are scoped by workflow instance and executor. Their full entity identity also supplies a stable external-provider @@ -226,8 +229,12 @@ slice into persisted session state on a `store=False` run. An external primary a that role, so it needs no additional durable provider. Changing `store` does not migrate service history, create placeholders for missing content, or -promote mailbox responses into the transcript. Preserve the service conversation ID and effective -options. Explicit ownership migration or forking belongs in the core lifecycle follow-up. +promote mailbox responses into the transcript. In a `store=True -> False -> True` sequence, exclude +the saved service conversation ID from the client-owned model invocation and history-provider hook +decisions, while retaining it in session control. The later service-owned run may resume that +service branch if still valid, without importing the intervening client-owned transcript. Neither +branch receives history synthesized from mailbox results. Explicit ownership migration or forking +belongs in the core lifecycle follow-up. ## Execution, Delivery and Session Lifecycle @@ -272,6 +279,11 @@ already-completed status, not another agent invocation. Transcript compaction an alter results, remove live mailbox obligations, or erase completion receipts. Runtime-error results and receipts are not model context. +An indefinitely active entity accumulates tombstones without a fixed bound. They can eventually fill +the non-evictable floor even after transcript pruning. This long-session limitation requires +[bounded completion bookkeeping](#7-bounded-completion-bookkeeping) as a durable follow-up, not +automatic receipt expiry under transcript retention. + The transcript and mailbox may share immutable payload storage, but a delivery reference cannot depend solely on an evictable transcript entry. It must stay readable for its delivery window. Likewise, an orchestration records the `call_entity` result for replay, independently of entity @@ -279,9 +291,10 @@ completion records and any assistant message retained as history. ### Session restoration -Create each operation's session through the agent's own `create_session()` and restore its provider -state and service conversation ID. Carry the resulting session forward for committed successes and -errors, including pending tool approvals. The current Python serialization bridge uses +Create each operation's session through the agent's own `create_session()` and restore provider +state. Apply the [per-run ownership rules](#per-run-ownership) to the saved service conversation ID. +Carry the resulting session, pending tool approvals and any inactive service conversation ID +forward on committed successes and errors. The current Python serialization bridge uses `AgentSession.to_dict()` with JSON-compatibility validation. The state bag may contain more than metadata, and its full serialized size counts toward the entity budget. @@ -305,6 +318,12 @@ External providers and the model service have independent commits. Their writes the entity commits. Local slice consistency therefore does not provide a distributed transaction or exactly-once execution of uncommitted effects. +An external append followed by a worker failure before local commit leaves no completion receipt +for that attempt. Retrying can append the same logical write again, even with only one append path. +Existing core history providers remain supported without a new idempotency requirement. Stronger +external-write guarantees are an optional +[durable integration capability](#8-retry-safe-external-history-writes). + If capacity prevents the entity commit, even a durable error result may not fit. Report failure through the operation error channel where available and through diagnostics. A state-polling signal caller may time out instead. Do not persist a successful completion receipt for an uncommitted turn. @@ -402,20 +421,20 @@ selected history owner. Honor `AgentExecutor.context_mode`: `full` (the default), `last_agent`, or `custom` with a `context_filter` of type `Callable[[list[Message]], list[Message]]`. Project the upstream `AgentExecutorResponse.full_conversation` first, then send the target's unseen positions as -`RunRequest.context_messages`. These are invocation inputs, not a -mandatory entity-local transcript mirror. +`RunRequest.context_messages`. Preserve the selected order among those new messages. These are +invocation inputs, not a mandatory entity-local transcript mirror. ```mermaid flowchart TB subgraph ORCH["Durable workflow orchestrator, re-executed every episode"] FC["full_conversation"] PROJ["L3: context_mode / context_filter
full, last_agent, custom"] - DELTA["Select unseen positions for this target
Replay-derived target and producer cursor"] + DELTA["Select unseen positions for this target
Replay-derived delivery bookkeeping"] FC --> PROJ --> DELTA end subgraph NODE["Agent node, the same execution contract as standalone"] - GUARD["ingestedPositions
Reject repeated positions"] + GUARD["Ingestion receipts
Reject delivered positions, not skipped ones"] ENTITY["AgentEntity for this workflow node
Execution, delivery and session control"] INNER["Inner agent + selected history owner
Configured compaction and retention apply"] GUARD --> ENTITY --> INNER @@ -426,20 +445,35 @@ flowchart TB ``` Projection controls which context may reach a target. Delta transport avoids repeatedly sending the -same permitted prefix without changing that semantic choice. Entity-side deduplication occurs too +same selected messages without changing that semantic choice. Entity-side deduplication occurs too late to reduce the serialized call payload. The prototype's complete-projection measurements are in [Prototype Evidence](#prototype-evidence). -Workflow messages use `wf_{executor}_{position}` identities. Maintain a separate transport cursor -for each `(target, producer)` pair, reconstructed from deterministic orchestration replay rather -than checkpointed independently. Fan-out targets advance separately. Fan-in compares each message -only with its own producer's cursor, never a minimum or maximum across different producers. +Workflow messages use `wf_{executor}_{position}` identities. Maintain separate delivery bookkeeping +for each `(target, producer)` pair, reconstructed through deterministic orchestration replay rather +than checkpointed independently. Fan-out targets advance separately. Fan-in checks each message +against its own producer's delivery record, never a minimum or maximum across different producers. + +Custom filters need not be position-monotonic. A scalar highest-position cursor is an internal +optimization only where Durable can establish that it preserves the delivery decision. Otherwise, +track actual sent and ingested positions, using sets or lossless ranges that preserve gaps. For +example, after delivering positions `[1, 3]`, a later projection `[2, 4]` must deliver both `2` and +`4`. Purity, determinism and sorting each projection do not make position `2` already delivered. -The entity persists `ingestedPositions` as the redelivery guard. Neither its position map nor -transport cursors rewind when transcript retention removes messages. If the entity is ahead of a -replay-derived cursor, it rejects repeated positions and accepts newer ones. It must not request an -evicted prefix again. This preserves configuration parity, not identical repeated-message counts in -every cycle compared with an in-process workflow. +Both source-side delta selection and entity-side redelivery checking must preserve this distinction. +Resending a full projection is insufficient if the entity still rejects every position below its +maximum. Persist ingestion receipts with the entity operation's other local changes. Neither side +forgets delivery evidence when transcript retention removes message content. A previously delivered +and evicted message must not be re-ingested merely because its transcript entry is gone. + +Exact position bookkeeping can grow for sparse selections. Count persisted ingestion receipts in +the non-evictable floor, and report capacity failure rather than silently losing selected context or +discarding delivery evidence. This preserves selection of previously undelivered context, not +identical repeated-message counts in every cycle compared with an in-process workflow. + +Position tracking covers existing workflow message identities. Changed content under an already +delivered identity or synthesized messages need separate identity and update handling in Durable. +Position tracking alone does not establish full filter parity for those cases. Custom IDs outside the workflow format are not monotonic positions. The prototype uses a `known_ids` lookup derived from stored message envelopes. Before omitting externally owned message @@ -449,10 +483,11 @@ completed request. Neither form of deduplication implies that local IDs match an ### Replay constraints and projection placement -A `context_filter` must be synchronous, deterministic, side-effect-free and independent of time, -randomness or external state. It runs inside orchestration replay, potentially more than once for -a logical handoff. `full` and `last_agent` are deterministic list projections. A custom filter's +While executed inside orchestration replay, a `context_filter` must be synchronous, deterministic, +side-effect-free and independent of time, randomness or external state. It can run more than once +for a logical handoff. `full` and `last_agent` are deterministic list projections. A custom filter's side effects can repeat, its I/O can fail a later replay, and its latency is paid on each episode. +These execution-location constraints do not require position-monotonic selection. The evaluated Durable Task SDK compares action identity and kind, not action-input equality. A different recomputed input can be discarded in favor of the recorded result without a @@ -509,6 +544,11 @@ completion receipts. Preserve recorded outcomes, correlations, message IDs, orde session state and ingestion bookkeeping. Keep `conversationHistory` in place where possible rather than requiring a bulk transcript relocation. +Exact ingestion receipts require the same reader/writer and rollback gates. A scalar maximum does +not reveal which lower positions were skipped. Require recorded delivery evidence or an explicit +version-gated transition for such state; do not infer a fully delivered prefix or reconstruct +receipts solely from the pruned transcript. + Only recorded outcomes justify backfilled completion receipts. If old retention already removed an outcome, migration cannot reconstruct it or claim that duplicate suppression covered that request. An existing response may itself have been partially pruned or annotated. Preserve its available @@ -538,11 +578,15 @@ polling after transcript pruning, Python/.NET round-trips and unknown-data prese - Eager pruning and pressure eviction can be enabled separately. Both remain non-deleting by default, so an unconfigured session can still reach its backend limit. - Pruning cannot change an original result or erase completion evidence. Those protected records - consume capacity and can prevent further writes even when transcript retention is enabled. + accumulate throughout an active entity's lifetime and can prevent further writes even when + transcript retention is enabled. Bounded completion bookkeeping remains a durable follow-up. +- Custom selection can require growing sets of ingestion receipts. That control-state cost belongs + to Durable rather than a new monotonicity requirement on core filters. - Pressure eviction changes available future history, not the current model projection. It operates near the configured budget rather than deleting continuously. - Local slices commit together, but external writes and uncommitted tool effects can repeat after - failure. This is not a cross-store transaction or exactly-once side-effect guarantee. + failure. Optional retry-safe history adapters do not make the entity and store transactional or + guarantee exactly-once model/tool effects. - The state transition requires compatible workers and polling clients even if the transcript retains its field name. In-flight workflows and supported rollback are release requirements. - The Python integration uses a session-buffer bridge until core exposes provider lifecycle APIs. @@ -562,23 +606,36 @@ existing prototype's coverage. 3. **Retention matrix.** Exercise all four combinations of eager pruning and pressure budget, explicit provider overrides, `"backend_limit"`, custom watermarks and unresolved host limits. Test system messages, newest exchanges, atomic tool/reasoning groups, metadata-only floors, - oversized results, unreachable targets and truncation evidence. + growing completion/ingestion receipts, oversized results, unreachable targets and truncation + evidence. 4. **Session continuity.** Restore provider types, pending approvals and service conversation IDs - on committed success/error paths. Test per-run `store` transitions with and without a service - ID, preserving current input and skipping contentless legacy records without importing mailbox - results into history. Exercise bounded matching-error retries and immediate failure on others. -5. **Workflow inputs.** Test cycles, fan-out, fan-in, replay, cursors and evicted prefixes. - Distinguish repeated context under a new correlation from repeated request delivery. Include - custom, missing and fully repeated message IDs, plus deterministic custom projection. + on committed success/error paths. Cold-reload through `store=True -> False -> True` with a + valid saved service ID. The client-owned run must ignore that ID in model calls and history + hooks; the later service-owned invocation must receive the preserved ID. Neither transcript may + be synthesized from mailbox results or merged with the other. Also cover transitions without a + service ID, current-input preservation and contentless legacy records. Exercise bounded + matching-error retries and immediate failure on others. +5. **Workflow inputs.** Test cycles, fan-out, fan-in, replay, delivery receipts and eviction. + A custom projection `[1, 3]` followed by `[2, 4]` must deliver both `2` and `4` on the second + visit at the sender and receiver, including after cold reload and transcript eviction. Assert + previously delivered positions are not re-ingested, each target/producer advances independently, + and the selected order is preserved. Distinguish repeated context under a new correlation from + repeated request delivery. Include custom, missing and fully repeated message IDs, plus + deterministic non-monotonic projection and any cursor fast path's equivalence to exact tracking. 6. **Registration.** Verify no-primary and sink-only injection, in-memory replacement, preserved `source_id`/`skip_excluded`, explicit `prune_excluded` precedence, external-provider preservation and rejection of multiple load-enabled primaries. 7. **State transition.** Test legacy reading, idempotent conversion, old/new polling, paused human-in-the-loop (HITL) resumes and new requests after rollback. Include partially altered - legacy results, expiry grace, Python/.NET rewrites and unknown-data preservation. + legacy results, expiry grace, Python/.NET rewrites and unknown-data preservation. Test scalar + ingestion state with missing delivery evidence rather than assuming every earlier position was + delivered when converting to exact receipts. 8. **Failure boundaries.** Inject failures around local commit and external writes. Uncommitted effects must not become protected completed operations. Verify the polling timeout/error - behavior when capacity prevents even an error-response commit. + behavior when capacity prevents even an error-response commit. Include an ordinary append-only + provider whose write succeeds before a worker failure and can repeat on retry; do not claim + duplicate-free external storage for it. Retry-safe adapters, when added, require separate + failure-injection tests for their declared guarantees. Live scheduler-limit tests, offload validation and cross-language compaction parity remain required as those capabilities are implemented. Any future LLM-based reducer also needs stable summary @@ -591,6 +648,9 @@ assertion that later package versions retain every limitation. Revalidate each d the versions selected for its implementation PR. New follow-up issues will be filed after ADR approval. +Bounded completion bookkeeping and retry-safe external writes are durable-owned follow-ups. They +do not add mandatory capabilities to core history providers. + ### 1. Provider-owned store reduction The evaluated Python `CompactionProvider.after_strategy` mutates @@ -666,6 +726,34 @@ Backend metadata for `max_state_bytes="backend_limit"` is also needed where the a hard limit. Do not silently infer an unlimited or offloaded budget in that case. An explicit byte budget remains the portable option. +### 7. Bounded completion bookkeeping + +Evaluate acknowledgement plus a defined redelivery window, compact sequence watermarks where the +request protocol permits them, or offloaded completion receipts. Any bound must define what happens +to a duplicate request after its receipt expires without silently allowing completed work to run +again. Idle-session TTL does not bound an entity kept active by new requests. This work does not +block the initial implementation; until a replacement protocol is defined, tombstones remain until +entity deletion and their growth remains an explicit capacity limitation. + +### 8. Retry-safe external history writes + +Provide stronger append guarantees through optional durable-owned adapters or integration +capabilities using the existing core history-provider API. Do not require every provider to change +its implementation, and do not silently replace a user's selected external provider. + +A retry-safe integration needs a stable write identity scoped to provider, session, logical request +and append step, established before the write and reused on retries. The backing store must +atomically apply the append and its duplicate-detection record, or support an expected-version +protocol that distinguishes a prior successful write from a conflicting one. Define behavior for +the same identity with different content, partial batches and receipt expiry before claiming +duplicate-free writes. Preserve provider callback cadence, including multiple appends within a run. + +An entity-only receipt, activity or outbox does not alone close the external-write/acknowledgement +gap. The stronger guarantee requires backing-store cooperation, but not a universal core API change +or an entity-side transcript mirror. It protects history appends, not repeated model calls or tool +effects. Existing providers remain supported with the documented possible-duplicate behavior; this +capability does not block their initial integration. + ### Release gates and excluded scope - State and response-consumer compatibility must precede revised writes, following @@ -702,7 +790,9 @@ Its retention tests cover the original `keep_all`, `auto` and `follow_compaction independent controls specified here. Twenty-turn tests use a reduced budget with `keep_all` as the control. Scheduler integration covers persisted metadata, external-provider session identity, schema conformance, downstream workflow context and a Redis-owned conversation. This does not -validate the proposed mailbox/receipt layout or source-side delta transport. +validate the proposed mailbox/receipt layout, exact delivery tracking, source-side delta transport +or `store=True -> False -> True` service-branch isolation. The target's `ingestedPositions` remains +a per-producer maximum, and the Redis sample appends without a retry receipt. ### Recorded observations From e435badc670cf993972827baa2dfc4e818874b03 Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Thu, 10 Sep 2026 14:00:30 -0500 Subject: [PATCH 10/13] docs: clarify durable runtime contracts and validation boundaries --- .../0032-durable-thread-compaction.md | 670 ++++++++++++------ 1 file changed, 454 insertions(+), 216 deletions(-) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index 33d9191..761e3ac 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -15,24 +15,40 @@ Use core's history-provider abstraction for durable conversation storage, togeth context projection and per-target delta transport (Options 6 and 4). Keep execution and result delivery independent of transcript ownership. +### Shared correctness invariants + - The entity owns request correlation, completion, original result delivery and duplicate-request suppression for every history configuration. - The selected history owner supplies the transcript. Durable-owned history remains entity-local. External and service-owned history does not require an entity-side message mirror. -- Workflow delta transport preserves custom selection. Position monotonicity is a cursor - optimization condition, not a requirement on custom filters. +- The outgoing workflow conversation preserves the full logical selection, separately from the + target's new-message delta. Position monotonicity is a cursor optimization condition, not a + requirement on custom filters. - Core compaction controls model input. Eager transcript pruning and pressure eviction are separate - opt-ins, defaulting to `retention="keep_all"` and `max_state_bytes=None`. + opt-ins. The proposed shared defaults are non-deleting `keep_all` and no pressure budget (`None`). - All entity-local slices share one size budget and commit at one operation boundary. External writes and tool side effects are outside that transaction. -- New state writers require compatible workers and polling clients, including supported rollback - behavior for in-flight sessions and workflows. +- Shared rollout requires proven reader, writer, client and orchestration-history compatibility, + including rollback targets. Isolated rollout keeps old executions on their old engine and hub. + +### Runtime capability mapping + +These are integration proposals, not claims that aligned defaults or all capabilities have shipped. +Shared invariants do not require identical core APIs or the prototype's private APIs and JSON shape. + +| Surface | Python integration | .NET integration | +| --- | --- | --- | +| Provider configuration | Load-enabled `HistoryProvider`, `source_id`, store-only sinks | Singular `ChatHistoryProvider`, separate `AIContextProviders` | +| Ownership policy | Per-run `store`, including `True -> False -> True` | Initially session-stable proposal, not a core API limit. Honor supported overrides, reject unsupported transitions. | +| Eager pruning | `follow_compaction` on supported `DurableHistoryProvider`, not external stores | Defer `FollowCompaction` pending safe exclusion, summary, cadence and decorator handling. | +| Pressure retention | Independent of compaction | Independent of compaction. `Auto` denotes pressure behavior, not automatic core compaction. | +| Defaults and budget | `keep_all` and `None` by default; an explicit positive byte budget enables pressure eviction | Require aligned non-deleting defaults and equivalent explicit-budget semantics. Do not assume they have shipped. | -This ADR specifies a proposed contract for Python and .NET. The existing Python prototype uses a -combined execution/transcript layout. Its coverage and limitations are recorded in -[Prototype Evidence](#prototype-evidence), separately from the implementation requirements below. +Architectural decisions remain in [ADR PR #88][adr-pr]. Coverage and limitations of the published +Python prototype are recorded in [Prototype Evidence](#prototype-evidence), separately from the +implementation requirements below. -**Sections** +### Sections - [Context and Terminology](#context-and-terminology) - [Considered Options](#considered-options) @@ -83,27 +99,42 @@ tool-call/result and reasoning groups, while keeping deletion explicit and obser | Term | Meaning in this ADR | | --- | --- | | History provider | Python `HistoryProvider` or .NET `ChatHistoryProvider` | -| Primary provider | A history provider with loading enabled. Additional providers may be store-only sinks. | +| Primary provider | In Python, a history provider with loading enabled. Additional providers may be store-only sinks. .NET has a singular history-provider slot. | | Transcript | Messages and associated history/compaction metadata, distinct from execution receipts | | `conversationHistory` | The entity's existing persisted transcript field, not a requirement for every history owner | | Execution and delivery state | Request-level bookkeeping, original results and completion receipts | | Service-storing client | A client whose default is service-side storage | | Service-owned run | A run for which the model service supplies history, determined from effective options | -| `source_id` / `history_source_id` | The provider's identifier / the identifier a compaction provider uses to locate that history | +| `source_id` / `history_source_id` | Python provider identifier / the identifier a Python compaction provider uses to locate that history | | Non-evictable floor | Serialized entity data that transcript retention cannot remove | +### Identity glossary + +| Identity | Purpose and scope | +| --- | --- | +| Request correlation | Identifies a logical request within an entity for result lookup and duplicate-request suppression. Not a message or external append ID. | +| Application message ID | Public, caller/provider-owned message identity. Preserve it rather than rewriting it to carry transport bookkeeping. | +| Transport occurrence and revision fingerprint | Identifies a scoped occurrence and its content revision for workflow delivery. Distinguishes repeated IDs, changed messages and synthesized messages without relying on text alone. | +| Stable session key | Identifies one durable session to its provider. Workflow instance and executor scope must prevent unrelated nodes from sharing it. | +| Provider continuation | A service conversation/response ID or provider continuation token. It resumes that provider's branch, not an entity result or workflow cursor. | +| External append ID | Optional retry-safe adapter identity for a logical write step. It is not supplied by a local message ID or required of ordinary core providers. | + +These identities have different lifetimes. None requires a per-message external-history mirror or +a public message-ID rewrite. Transport receipts belong in control state, not placeholder messages. + The labels L1, L2 and L3 identify different integration points, not three forms of storage eviction. | Surface | Mechanism | Effect | | --- | --- | --- | | L1, agent context | Core `CompactionProvider` / `compaction_strategy` | Projects model input without deleting stored history | -| L2, eager pruning | `retention="follow_compaction"` | Opt-in deletion of excluded local transcript messages | +| L2, eager pruning | Python `retention="follow_compaction"`, proposed .NET equivalent | Opt-in deletion on a supported local history path | | L3, workflow context | `context_mode` / `context_filter` and delta transport | Selects and transports context between executors | | Capacity safety | Optional `max_state_bytes` budget | Evicts eligible local transcript groups under pressure, independently of L2 | -The Python prototype demonstrates the L1/L2 integration. The evaluated .NET compaction-state -representation has an additional storage constraint described in dependency 4. The target contract -does not imply that both implementations already provide every capability. +The Python prototype demonstrates the L1/L2 integration on its durable history path. The evaluated +.NET compaction-state representation has an additional storage constraint described in dependency +4. Eager-pruning parity also needs observable summary, cadence and decorator paths. Neither that +work nor external-store rewrite support is a prerequisite for independent pressure retention. ## Considered Options @@ -123,9 +154,8 @@ does not imply that both implementations already provide every capability. own lifecycle policy. 7. **Large-payload offload, optional and backend-specific.** The DTS [large-payload extension][offload] raises the ceiling without deleting content, but requires an - Azure Blob payload store and is not - available through every host. Azure Storage already offloads internally. No portable guarantee - in this ADR depends on offload being present. + Azure Blob payload store and is not available through every host. Azure Storage already offloads + internally. No portable guarantee in this ADR depends on offload being present. ## Ownership and State Model @@ -137,7 +167,7 @@ transcript. | History owner | Transcript location | Transcript policy | | --- | --- | --- | -| `DurableHistoryProvider` | Entity-local `conversationHistory` | Configured eager pruning and pressure eviction | +| Durable history adapter | Entity-local `conversationHistory` | Supported eager pruning and independent pressure eviction | | External primary provider | Redis, Cosmos, file or its chosen store | The provider's own retention policy | | Model service | The service | Service retention, continued through its conversation ID | | Agent without a context pipeline | Entity-local history through legacy replay | Optional pressure eviction | @@ -145,7 +175,7 @@ transcript. ```mermaid flowchart TB COMMON["Every run uses the same entity contract
Execution, delivery, session and workflow control"] - COMMON --> OWNER{"Who owns history on this run?"} + COMMON --> OWNER{"History owner under the runtime's supported policy?"} OWNER -->|"Durable provider or legacy replay"| LOCAL["Entity-local transcript
Messages, IDs and annotations"] OWNER -->|"External provider"| EXTERNAL["Provider's store
No required entity-side message mirror"] OWNER -->|"Model service"| SERVICE["Service transcript
Conversation ID in entity session state"] @@ -167,7 +197,7 @@ relocating `conversationHistory`. flowchart LR ENTITY["One entity / one session
One total size budget"] ENTITY --> EXEC["Execution and delivery, every owner
Request-level bookkeeping
responseMailbox + completedCorrelations"] - ENTITY --> CONTROL["Session and workflow control, as needed
session + ingestion receipts
Custom-ID deduplication bookkeeping"] + ENTITY --> CONTROL["Session and workflow control, as needed
session + ingestion receipts
Occurrence and revision bookkeeping"] ENTITY --> HISTORY["Local transcript, when used
conversationHistory
Messages, IDs, annotations + truncation"] ``` @@ -183,51 +213,62 @@ workflow instance and executor. Their full entity identity also supplies a stabl session key, so workflow nodes cannot accidentally share a conversation. Old sessions occupy separate entities and do not consume a new session's capacity. -### Provider selection +### Python provider selection -Registration selects the history adapter without changing the execution contract or mutating the -caller's agent. Preserve `source_id` when replacing a provider so compaction configured through -`history_source_id` continues to resolve the same history. Core's default history source is +Python registration selects the history adapter without changing the execution contract or mutating +the caller's agent. Preserve `source_id` when replacing a provider so compaction configured through +`history_source_id` continues to resolve the same history. Python core's default history source is `"in_memory"`. Substitution preserves existing compaction triggers and does not enable compaction. +The table and diagram below describe Python, not a universal provider-registration algorithm. | Configuration | Registration behavior | | --- | --- | | No load-enabled primary, including sink-only configurations | Inject durable history using core's default `source_id`. Preserve store-only sinks. | -| `InMemoryHistoryProvider` | Replace it with durable history, preserving `source_id` and `skip_excluded`. | +| Exact built-in `InMemoryHistoryProvider` | Replace it with durable history, preserving `source_id`, storage flags and `skip_excluded`. Preserve custom subclasses and their hooks. | | Hand-configured `DurableHistoryProvider` | Preserve explicit `prune_excluded`. If unset, inherit the registration retention policy. | | External load-enabled primary | Keep it. Do not add durable history alongside it. | | Service-storing client without an external primary | Keep durable history available for client-owned runs and silent on service-owned runs. | | No core context pipeline | Preserve the legacy entity-local replay path. | Reject more than one load-enabled primary. Additional store-only audit or evaluation sinks retain -their configured storage and lifecycle. If substitution is needed, shallow-copy the agent and its -provider list. Substitution does not import content accumulated in an in-memory provider before -registration, and that import scenario is outside the persisted-state upgrade contract. +their configured storage and lifecycle. A Python audit sink must have loading disabled and a +nonempty, unique `source_id`. If substitution is needed, shallow-copy the agent and its provider +list. Implicit history follows core's provider ordering. Preserve custom durable-provider session +state while excluding only its derived message buffer and position index. A custom in-memory +subclass's session transcript remains part of the protected state budget, not the durable +transcript's eviction policy. Substitution does not import content accumulated in an in-memory +provider before registration; that import scenario is outside the persisted-state upgrade contract. ```mermaid flowchart TB - PIPE{"Core context pipeline?"} + PIPE{"Python core context pipeline?"} PIPE -->|"No"| LEGACY["Keep legacy entity-local replay"] PIPE -->|"Yes"| COUNT{"Load-enabled primary providers?"} COUNT -->|"More than one"| REJECT["Reject registration"] - COUNT -->|"None, including sink-only"| INJECT["Inject durable provider
Preserve store-only sinks"] + COUNT -->|"None, including sink-only"| INJECT["Inject Python durable provider
Preserve store-only sinks"] COUNT -->|"One"| TYPE{"Which primary?"} - TYPE -->|"In-memory"| REPLACE["Replace with durable provider
Preserve source_id and skip_excluded"] + TYPE -->|"Exact built-in in-memory"| REPLACE["Replace with durable provider
Preserve source_id, flags and skip_excluded"] TYPE -->|"Durable"| KEEP["Keep explicit pruning choice
Otherwise inherit retention setting"] - TYPE -->|"External"| EXTERNAL["Keep external provider
Do not add durable alongside it"] + TYPE -->|"External or custom subclass"| EXTERNAL["Keep selected provider
Do not add durable alongside it"] ``` -### Per-run ownership +### Python per-run ownership -Resolve `store` from run options, then agent defaults, then the client's `STORES_BY_DEFAULT`. -Durable follows core's per-run choice rather than pinning an owner for the session. An attached -durable provider yields no local history on a service-owned run and is available on client-owned -runs. These are ownership states, not retention modes. +Resolve Python `store` from run options, then agent defaults, then the client's `STORES_BY_DEFAULT`. +The Python integration follows that per-run choice rather than pinning an owner for the session. +An attached durable provider yields no local history on a service-owned run and is available on +client-owned runs. These are ownership states, not retention modes. Keeping the durable provider available prevents core from injecting an unmanaged in-memory history slice into persisted session state on a `store=False` run. An external primary already occupies that role, so it needs no additional durable provider. +On the service-owned branch, the prototype suppresses both loading and storing through an inactive +external primary, including per-service-call storage hooks. This intentionally differs from ordinary +Python core behavior, which can still store through that primary on a service-owned run. It is a +proposal for the Python integration, not a universal cross-runtime policy. Explicit store-only +audit sinks remain separate and retain their configured writes and lifecycle. + Changing `store` does not migrate service history, create placeholders for missing content, or promote mailbox responses into the transcript. In a `store=True -> False -> True` sequence, exclude the saved service conversation ID from the client-owned model invocation and history-provider hook @@ -236,6 +277,15 @@ service branch if still valid, without importing the intervening client-owned tr branch receives history synthesized from mailbox results. Explicit ownership migration or forking belongs in the core lifecycle follow-up. +### .NET ownership policy + +.NET configures a singular `ChatHistoryProvider` separately from its `AIContextProviders`. An +initially session-stable ownership policy is proposed for the durable integration. It is not a +claim that core cannot support per-run overrides or transitions. Implemented overrides must honor +core's effective choice. Reject unsupported overrides or transitions explicitly before execution, +rather than silently forcing the saved owner over that choice. Do not infer Python's injection or +inactive-primary storage behavior from this policy. + ## Execution, Delivery and Session Lifecycle The execution path is the same for every history owner. Workflow projection and delta selection @@ -262,7 +312,11 @@ sequenceDiagram E->>E: Evict eligible local transcript groups end E->>E: Commit entity-local state together - E-->>C: Result, directly or through polling + alt Commit confirmed + E-->>C: Committed outcome, directly or through polling + else Commit failed or acknowledgement uncertain + E-->>C: Failure or unresolved status, not invented completion + end end ``` @@ -273,8 +327,38 @@ the original success or runtime-error result, including response metadata, as a reference until a configured delivery expiry. The existing polling API cannot acknowledge receipt, so expiry bounds payload retention. An acknowledgement operation is a possible later capability. -When delivery expires, remove the payload or reference but retain a `completedCorrelations` -tombstone until the entity is deleted. A duplicate correlation returns its retained result or an +| Observed outcome | Delivery contract | +| --- | --- | +| Committed success | Return the original result, including an explicit null, falsey or non-text value. Completion does not depend on nonempty text. | +| Committed error | Return the recorded error and completion evidence. Do not reinterpret it as a successful empty response. | +| Expired delivery | Return already-completed/expired status from the receipt. Do not invoke again or resurrect a transcript response. | +| No result | Absence alone establishes neither acceptance, completion nor failure. Poll within the caller's deadline or report unresolved status. | +| Accepted | Report only actual acceptance/dispatch evidence. Acceptance is not final completion. | +| Pending approval | Deliver the approval request and persist pending state. This does not mean the guarded tool action executed successfully. | + +Define a JSON projection of the supported response fields, including messages/content, message and +response IDs, author/agent metadata, original creation time, usage, finish reason, provider +continuation and additional properties. Preserve structured values separately from text, including +the distinction between an absent value and explicit `null`, `false`, `0` or an empty container. +Property presence or an explicit marker can encode that distinction; this ADR does not mandate a +new flag. Do not persist opaque SDK objects or Python/.NET response-format classes as the contract. +Preserve supported tool and multimodal content without flattening it. Exact field names and +discriminators belong to schema review, not the prototype's private shape. + +Preserve unknown optional JSON data safely for round-trips, without dynamically loading types or +executing content merely by reading it. Opaque SDK representations are outside this guarantee. +The original payload and its metadata need not remain available after delivery expiry. Referenced +resource lifetime is separate from result lifetime; a retained URI does not guarantee the resource +still exists. Failure to read an offloaded delivery payload must not become a successful empty +result. An approval response may complete one request's delivery while the pending action still +needs an explicit resume under a new correlation. A recovered tool-role error is not automatically +a terminal invocation failure. + +At delivery expiry, stop returning the payload even if physical cleanup is lazy. Remove the +payload or reference during a subsequent operation or explicit maintenance, but retain a +`completedCorrelations` tombstone until the entity is deleted. Idle physical cleanup requires a +host/application-owned schedule. The Python prototype exposes an `expire_responses` entity operation +rather than an implicit idle timer. A duplicate correlation returns its retained result or an already-completed status, not another agent invocation. Transcript compaction and clearing must not alter results, remove live mailbox obligations, or erase completion receipts. Runtime-error results and receipts are not model context. @@ -291,16 +375,17 @@ completion records and any assistant message retained as history. ### Session restoration -Create each operation's session through the agent's own `create_session()` and restore provider -state. Apply the [per-run ownership rules](#per-run-ownership) to the saved service conversation ID. -Carry the resulting session, pending tool approvals and any inactive service conversation ID -forward on committed successes and errors. The current Python serialization bridge uses -`AgentSession.to_dict()` with JSON-compatibility validation. The state bag may contain more than -metadata, and its full serialized size counts toward the entity budget. - -Exclude the durable history provider's transient working buffer from session serialization, not -the durable transcript itself. Rebuild that buffer from persisted messages, IDs and annotations on -each turn. Reconcile compaction changes by message ID before dropping the transient slice. Core's +Restore each operation through its runtime's session/provider lifecycle. Python uses the agent's +own `create_session()` and applies the [per-run rules](#python-per-run-ownership) to the saved service +conversation ID. Carry the resulting session, pending tool approvals and any inactive service +conversation ID forward on committed successes and errors. The current Python serialization bridge +uses `AgentSession.to_dict()` with JSON-compatibility validation. The state bag may contain more +than metadata, and its full serialized size counts toward the entity budget. + +In the Python bridge, exclude only the durable provider's transient buffer and position index from +session serialization, not the durable transcript itself. Rebuild them from persisted messages, +IDs and annotations on each turn. Keep the provider's other JSON-compatible session state. +Reconcile compaction changes by message ID before dropping the transient fields. Python core's process-local type registry requires registering already loaded serializable types during restore. Broad Pydantic subclass discovery is not used because of identifier-collision risk. @@ -311,9 +396,23 @@ See [provider lifecycle dependencies](#dependencies-and-follow-up-work) for the Execution, session, ingestion and local transcript changes commit together once per entity operation. A completion receipt proves a committed outcome, not completion of every intermediate -model or tool call. A worker failure before commit leaves the previous local state intact, and a +model or tool call. Failure known to precede commit leaves the previous local state intact, and a retry may repeat those calls and side effects. The design does not checkpoint between tool calls. +| Failure boundary | Required behavior | +| --- | --- | +| Caller stops waiting or cancels polling | Stop that wait only. This is not execution cancellation, and the entity may still commit. | +| Worker shutdown or execution cancellation before commit | Discard uncommitted local changes. Do not invent completion. External writes, model calls or tools may already have succeeded. | +| Cancellation after confirmed commit | The request is already completed. Return its recorded outcome rather than undoing it or invoking again. | +| Provider failure during load, invocation or store | Stage a runtime-error outcome if possible, with actual accepted-input receipts and resulting session state. Do not fabricate acceptance for rejected input. | +| Final reconciliation, session serialization or a known pre-commit failure | Leave the last committed local state intact. Do not return staged completion as a committed outcome. | +| Error outcome cannot be persisted | Use the direct operation failure channel where available. State-polling callers may time out without a durable error result. | +| Commit acknowledgement is uncertain | The outcome is unresolved until authoritative state or host evidence settles it. Do not claim the request did not execute or blindly resubmit under a new correlation. | + +Acceptance/ingestion receipts describe what was actually accepted, not all requested input. They +are distinct from final completion receipts. Cancellation state, where supported, must be explicit +and cannot be inferred from an empty result or caller timeout. + External providers and the model service have independent commits. Their writes may succeed before the entity commits. Local slice consistency therefore does not provide a distributed transaction or exactly-once execution of uncommitted effects. @@ -331,8 +430,14 @@ caller may time out instead. Do not persist a successful completion receipt for ### Service conversation errors For the structured `previous_response_not_found` error, retry the identical current invocation up -to three additional times within the operation, waiting 0.5, 1.0 and 1.5 seconds. Stop immediately -on a different error. If matching refusals continue, fail the turn. +to three additional times within the operation, waiting 0.5, 1.0 and 1.5 seconds, only if there has +been no streaming progress, tool execution or session/continuation advance. Stop immediately on a +different error or any such progress. If matching refusals continue, fail the turn. + +Never replay a partially consumed stream or restart a tool loop after progress. Retrying a whole +invocation before observable progress can still repeat provider hooks; a matching refusal does not +prove those hooks were side-effect-free. Preserve configured cadence and do not claim external +writes are retry-safe without the provider's own guarantee. These retries can recover transient visibility failures without retaining a duplicate transcript. A genuinely expired conversation ID cannot be recovered this way. Full-transcript recovery is not part @@ -343,18 +448,25 @@ storage trade-off are recorded in [Prototype Evidence](#prototype-evidence). Two independent registration controls govern transcript deletion. Application defaults may be overridden per agent. They do not change the agent's compaction configuration or an external -provider's storage policy. +provider's storage policy. The table uses Python configuration spellings. The shared contract is +non-deleting defaults, an optional explicit byte budget and independent eager pruning where +supported, not identical API names in both runtimes. | Control | Value | Behavior | | --- | --- | --- | | `retention` | `keep_all` **(default)** | Do not delete merely because compaction excluded a message. | -| `retention` | `follow_compaction` | Prune excluded local messages after each turn. Without compaction there are no exclusions to prune. | +| `retention` | `follow_compaction` | Prune exclusions on the supported Python durable-provider path. Not a rewrite policy for external stores. Without compaction there are no exclusions to prune. | | `max_state_bytes` | `None` **(default)** | Disable pressure eviction. The backend can still reject an oversized write. | -| `max_state_bytes` | `"backend_limit"` | Use the host's known hard payload limit, 1,048,576 bytes for direct DTS. Fail registration if unresolved. | +| `max_state_bytes` | `"backend_limit"` | Optional Python host convenience for a known DTS/Scheduler limit. Reject if unresolved. Not a transport-size guarantee. | | `max_state_bytes` | positive integer | Use that explicit serialized-state budget. | -Neither control enables the other. An explicitly pinned provider `prune_excluded` value takes -precedence over the registration retention mode. The matrix below assumes no such provider override. +`None` and a positive integer are the portable budget choices. A known direct DTS limit is +1,048,576 bytes, but serialized entity bytes exclude transport framing and other host overhead. +`"backend_limit"` is not a universal backend-discovery API or a guarantee that a write will fit. + +Neither control enables the other. In Python, an explicitly pinned provider `prune_excluded` value +takes precedence over registration retention. The matrix assumes a supported local pruning path +with no such provider override. | | No pressure budget | Pressure budget set | | --- | --- | --- | @@ -364,7 +476,12 @@ precedence over the registration retention mode. The matrix below assumes no suc ### Defaults and scope Deletion is opt-in because a model-input exclusion should not silently become irreversible storage -loss. The core configuration examined for this ADR likewise leaves in-memory history unbounded, +loss. Both durable runtimes must align on `keep_all` and no pressure budget by default. This is a +requirement, not a claim about shipped defaults. In .NET, `Auto` pressure semantics must not be +presented as enabling core compaction. Defer `FollowCompaction` until exclusion, summary, cadence +and decorator paths support safe deletion. Pressure retention need not wait for that work. + +The Python core configuration examined for this ADR leaves in-memory history unbounded, defaults `RedisHistoryProvider.max_messages` and `compaction_strategy` to `None`, and requires an explicit `max_context_window_tokens` for context-window compaction. That token setting governs L1, not entity storage. @@ -383,8 +500,9 @@ payload bytes free. When a pressure budget is set, evaluate it before the operation's state commit, after any enabled eager pruning. Configurable `high_watermark=0.85` and `low_watermark=0.70` must satisfy -`0 < low_watermark < high_watermark <= 1`. Below high, do nothing. At pressure, target low using -core's deterministic oldest-group fallback via `TokenBudgetComposedStrategy(strategies=[])`. +`0 < low_watermark < high_watermark <= 1`. Below high, do nothing. At pressure, target low with +deterministic oldest-group selection. Python uses core's fallback via +`TokenBudgetComposedStrategy(strategies=[])`. Equivalent .NET behavior need not use that API. Plan with detached messages whose exclusion flags are cleared, leaving stored annotations intact. All otherwise eligible old groups compete by age, including groups excluded from model context. @@ -406,42 +524,60 @@ estimate as the storage limit. No model call is needed for pressure eviction. Persist `truncation` with `evictedMessageCount`, `firstEvictedAt` and `lastEvictedAt`. Use bounded aggregate evidence, not a growing list of removed messages. Absence means no recorded transcript -eviction. This record describes lost model context, while `completedCorrelations` describes -completed execution. Neither substitutes for the other. +eviction. This record describes removed local transcript content, while `completedCorrelations` +describes completed execution. Neither substitutes for the other. + +Emit bounded OpenTelemetry events/measurements for the requested budget, serialized bytes before +and after staged deletion, removed-message count, non-evictable-floor failures and commit failures. +Distinguish a planned eviction from staged state and from a confirmed commit. Report confirmation +only where the host can observe the actual commit. Otherwise leave commit status unknown rather +than treating an entity method's return or a staging log as persistence evidence. + +Validation must pair persisted-state readback with the subsequent model input. Logs alone prove +neither durable deletion nor what the model received. Do not log message payloads or put session, +request, message or other high-cardinality IDs in metric dimensions. ## Workflow Context -Workflow agent nodes use the same `DurableAIAgent` to `AgentEntity` to inner-agent path as -standalone durable agents. They inherit execution, session, compaction and retention contracts. Only -inter-executor projection and transport are workflow-specific. Each node's transcript stays with its -selected history owner. +Workflow agent nodes use the same execution path as standalone durable agents, expressed in Python +as `DurableAIAgent` to `AgentEntity` to the inner agent. They inherit execution, session, compaction +and retention contracts. Only inter-executor projection and transport are workflow-specific. Each +node's transcript stays with its selected history owner. ### Projection and delta transport Honor `AgentExecutor.context_mode`: `full` (the default), `last_agent`, or `custom` with a `context_filter` of type `Callable[[list[Message]], list[Message]]`. Project the upstream -`AgentExecutorResponse.full_conversation` first, then send the target's unseen positions as -`RunRequest.context_messages`. Preserve the selected order among those new messages. These are -invocation inputs, not a mandatory entity-local transcript mirror. +`AgentExecutorResponse.full_conversation` first. Preserve the full selected logical conversation +and order at the workflow layer, separately from the per-target wire delta. Transport full message +objects for unseen occurrences or revisions, not flattened text. The target receives that delta +as new invocation input. Its selected history owner supplies prior context under the configured +core compaction and retention policies. Construct the outgoing workflow conversation from the +full selected logical input plus the actual response messages, not from the delta alone or an +unfiltered upstream conversation. This does not promise identical repeated-message counts or +reconstitution of evicted context at every model invocation. Python prototype names such as +`RunRequest.context_messages` do not fix the common public API or wire shape. ```mermaid flowchart TB - subgraph ORCH["Durable workflow orchestrator, re-executed every episode"] - FC["full_conversation"] - PROJ["L3: context_mode / context_filter
full, last_agent, custom"] - DELTA["Select unseen positions for this target
Replay-derived delivery bookkeeping"] - FC --> PROJ --> DELTA - end + subgraph ORCH["Durable workflow orchestrator, re-executed every episode"] + FC["Upstream full_conversation"] + PROJ["L3: context_mode / context_filter
Preserve exact logical selection and order"] + DELTA["Per-target wire delta of full messages
Occurrence + revision delivery bookkeeping"] + OUT["Outgoing full_conversation
Full selected logical input + actual response messages"] + FC --> PROJ --> DELTA + PROJ -->|"Retain full logical selection"| OUT + end - subgraph NODE["Agent node, the same execution contract as standalone"] - GUARD["Ingestion receipts
Reject delivered positions, not skipped ones"] - ENTITY["AgentEntity for this workflow node
Execution, delivery and session control"] - INNER["Inner agent + selected history owner
Configured compaction and retention apply"] - GUARD --> ENTITY --> INNER - end + subgraph NODE["Agent node, the same execution contract as standalone"] + GUARD["Ingestion receipts for the new delta
Do not confuse delivered with skipped"] + ENTITY["AgentEntity for this workflow node
Execution, delivery and session control"] + INNER["Inner agent + selected history owner
Configured compaction and retention apply"] + GUARD --> ENTITY --> INNER + end - DELTA -->|"New context_messages
Stamped workflow identities"| GUARD - INNER -->|"response"| FC + DELTA -->|"Full-message delta
Transport identity separate from public IDs"| GUARD + INNER -->|"Actual response messages"| OUT ``` Projection controls which context may reach a target. Delta transport avoids repeatedly sending the @@ -449,9 +585,11 @@ same selected messages without changing that semantic choice. Entity-side dedupl late to reduce the serialized call payload. The prototype's complete-projection measurements are in [Prototype Evidence](#prototype-evidence). -Workflow messages use `wf_{executor}_{position}` identities. Maintain separate delivery bookkeeping -for each `(target, producer)` pair, reconstructed through deterministic orchestration replay rather -than checkpointed independently. Fan-out targets advance separately. Fan-in checks each message +The prototype's legacy `wf_{executor}_{position}` format is not a normative workflow identity or a +reason to rewrite application message IDs. Use scoped transport occurrences and revision +fingerprints over full message content. Maintain separate delivery bookkeeping for each +`(target, producer)` pair, reconstructed through deterministic orchestration replay rather than +checkpointed independently. Fan-out targets advance separately. Fan-in checks each occurrence against its own producer's delivery record, never a minimum or maximum across different producers. Custom filters need not be position-monotonic. A scalar highest-position cursor is an internal @@ -464,22 +602,26 @@ Both source-side delta selection and entity-side redelivery checking must preser Resending a full projection is insufficient if the entity still rejects every position below its maximum. Persist ingestion receipts with the entity operation's other local changes. Neither side forgets delivery evidence when transcript retention removes message content. A previously delivered -and evicted message must not be re-ingested merely because its transcript entry is gone. +and evicted message must not be re-ingested merely because its transcript entry is gone. A receipt +proves delivery, not that the recipient still retains the payload. Transcript retention remains +meaningful, and receipts do not require an external-history mirror or automatic rehydration. Exact position bookkeeping can grow for sparse selections. Count persisted ingestion receipts in the non-evictable floor, and report capacity failure rather than silently losing selected context or -discarding delivery evidence. This preserves selection of previously undelivered context, not -identical repeated-message counts in every cycle compared with an in-process workflow. +discarding delivery evidence. This preserves previously undelivered selections and the outgoing +workflow context, not identical repeated-message counts in every cycle compared with an in-process +workflow. Position tracking covers existing workflow message identities. Changed content under an already -delivered identity or synthesized messages need separate identity and update handling in Durable. -Position tracking alone does not establish full filter parity for those cases. +delivered application ID, repeated or missing IDs, and synthesized messages require occurrence and +revision handling in Durable. Fingerprint the complete message, including non-text content and +relevant metadata. Position tracking or text equality alone does not establish filter parity. -Custom IDs outside the workflow format are not monotonic positions. The prototype uses a -`known_ids` lookup derived from stored message envelopes. Before omitting externally owned message -records, preserve or replace that lookup in control state with an explicit identity scope and -lifetime. Test repeated context under a new correlation separately from duplicate delivery of a -completed request. Neither form of deduplication implies that local IDs match an external store. +Custom IDs are not monotonic positions. The historical prototype used a `known_ids` lookup derived +from stored message envelopes. Removing externally owned message records requires independent +control-state receipts with an explicit identity scope and lifetime. Test repeated context under a +new correlation separately from duplicate delivery of a completed request. Neither form of +deduplication implies that local IDs match an external store. ### Replay constraints and projection placement @@ -502,52 +644,76 @@ and public accessors replacing `_context_mode` / `_context_filter` reads are tra ## State Evolution and Compatibility -The state schema is shared by Python and .NET, so additive JSON is not automatically a safe minor -version. The legacy readers evaluated during prototype development accept only `request` and -`response` entries. .NET polymorphic deserialization rejects unknown `$type` values, and Python's -fallback converts them through the same limited enum. Before emitting `errorResponse`, `compaction` -or revised delivery state, establish a compatible version floor. A major-only schema check does not -protect against unsupported minor additions. - -**Compatible reading includes behavior, not just JSON.** The prototype's polling paths use -`try_get_agent_response()` to find responses in `conversationHistory`. A client that only preserves -an unknown `responseMailbox` field would still fail to find a moved response. Workers, SDK clients -and HTTP polling code must understand both representations before a writer stops emitting the -legacy delivery representation. - -New entry kinds and lifecycle fields use a two-phase rollout. - -1. Ship compatible readers and response lookup in both runtimes. They accept legacy and revised - layouts, preserve unknown optional data, and keep unknown entry kinds out of model context. - Legacy state resolves completion from recorded response entries. Revised state resolves it from - the mailbox and completion receipts, distinguishing expired delivery from work not yet completed. - Workers must also maintain the revised write contract when handling an already-converted entity. -2. After every supported worker and response-reading client meets that reader floor, enable the - revised writer. A worker cannot inspect the versions of its peers or clients, so this is a - release and deployment gate, not a runtime handshake. +The shared Python/.NET schema needs behavioral compatibility, not just additive JSON or a matching +major version. The evaluated legacy readers accept only `request` and `response` entries. .NET +polymorphic deserialization rejects unknown `$type` values, and Python's fallback uses the same +limited enum. New entry kinds or delivery state therefore need explicit deployment gates. + +[Schema PR #92][schema-pr] is a separate review draft, not final format agreement or a replacement +for the architectural decisions in PR #88. + +### Mode 1. Shared deployment, conditional reader-first rollout + +Use this mode only after proving compatibility for both readers and writers in both runtimes, +SDK clients, HTTP polling, tooling and orchestration history, including every supported rollback +target. Preserving an unknown mailbox field is not enough if lookup still searches only +`conversationHistory`, or a later write discards the revised delivery state. + +1. Deploy dual-layout readers and workers that preserve the revised write contract. Legacy lookup + uses recorded response entries. Revised lookup distinguishes retained results, expired delivery + and work without a completed result. Preserve unknown optional raw JSON without executing it or + introducing unknown entry kinds into model context. +2. Prove response lookup, subsequent writes, paused-workflow replay and rollback against recorded + orchestration history. Only then enable revised writers. This is a release/deployment gate, + not a worker-to-worker or client-version handshake. + +The implementation stack must pin minimum worker, SDK/poller and tooling versions for both runtimes, +orchestration protocol versions and supported rollback targets before shared writes are enabled. +Those release floors are not selected here. Core dependency versions used in prototype tests are +not cross-runtime compatibility floors. + +### Mode 2. Isolated prototype v2 deployment + +The prototype's initial rollout is isolated v2, with separate task hubs and compatible workers and +clients. Its writer accepts its exact `2.0.0` layout. Legacy `1.x` state is read-only and requires +explicit migration before use by the new writer. Workflow protocol `2` is for new starts. Existing +workflows remain on their old engine and hub. Do not point old clients or workers at the v2 +destination. The required `isolated_v2` setting is operator acknowledgement, not proof that other +participants are isolated or compatible. + +Migration must be explicit, destination-bound and supplied by a trusted source. Bind it to the +intended destination entity/session, and require full journals where scalar ingestion maxima leave +gaps in delivery evidence. If evidence is missing, reject or defer migration rather than invent a +delivered prefix. A matching `2.x` version does not certify mixed-format compatibility. These are +prototype deployment constraints, not a mandate for its private APIs or exact final common schema. ```mermaid flowchart TB - READERS["Deploy dual-layout workers and polling clients"] - READERS --> READY{"All supported readers upgraded?"} - READY -->|"No"| OLD["Keep legacy writes"] - READY -->|"Yes"| NEW["Enable revised writer"] - NEW --> MAP["Normalize existing state idempotently
Preserve results, IDs and session control"] - MAP --> COMMIT["Commit revised layout at an operation boundary
Keep the transcript location where possible"] - COMMIT --> ROLLBACK["Rollback only to compatible workers and clients
Workers must preserve revised writes"] + MODE{"Deployment mode?"} + MODE -->|"Shared, conditional"| READY{"Readers + writers + SDK/HTTP/tooling
Replay history + rollback proven?"} + READY -->|"No"| OLD["Keep legacy writes
No in-flight compatibility assurance"] + READY -->|"Yes"| NEW["Enable revised writer
Idempotent staged conversion"] + NEW --> COMMIT["Commit at operation boundary
Preserve outcomes and control state"] + COMMIT --> ROLLBACK["Rollback only to proven compatible targets"] + MODE -->|"Prototype initial isolation"| ISOLATED["Separate v2 hub, workers and clients
Protocol 2 new workflow starts"] + ISOLATED --> LEGACY["Old workflows stay on old engine/hub
Legacy 1.x state is read-only"] + ISOLATED --> MIGRATE["Explicit destination-bound migration
Trusted source + full journals where scalar maxima leave gaps"] ``` -An in-flight workflow can resume on a new deployment with an old entity. Transition code must read -that state without replaying model or tool calls just to convert it. Conversion may be lazy at the -next entity operation, but repeated conversion must not duplicate messages, mailbox entries or -completion receipts. Preserve recorded outcomes, correlations, message IDs, order, annotations, -session state and ingestion bookkeeping. Keep `conversationHistory` in place where possible rather -than requiring a bulk transcript relocation. +### Conversion requirements within the chosen mode + +Only a proven shared rollout may resume an in-flight old workflow on the new deployment. Isolated +rollout makes no such assurance. Conversion must not replay model or tool calls just to convert +state. Shared-mode conversion may be lazy at the next entity operation. Repeated conversion in +either mode must not duplicate messages, mailbox entries or completion receipts. Preserve recorded +outcomes, correlations, application IDs, order, annotations, session state and ingestion bookkeeping. +Keep `conversationHistory` in place where possible rather than requiring bulk transcript relocation. Exact ingestion receipts require the same reader/writer and rollback gates. A scalar maximum does -not reveal which lower positions were skipped. Require recorded delivery evidence or an explicit -version-gated transition for such state; do not infer a fully delivered prefix or reconstruct -receipts solely from the pruned transcript. +not reveal which lower positions were skipped. Require complete recorded delivery journals for +that transition, not just a version gate. Do not infer a fully delivered prefix or reconstruct +receipts solely from the pruned transcript. In isolated mode, apply these checks at explicit import, +not as implicit permission for the new engine to resume an old orchestration. Only recorded outcomes justify backfilled completion receipts. If old retention already removed an outcome, migration cannot reconstruct it or claim that duplicate suppression covered that request. @@ -566,10 +732,11 @@ ownership from a locally generated message ID. Rollback is supported only to versions that preserve both lookup and write semantics for converted entities. Read-only tolerance is insufficient if the next operation writes its response only to the -old transcript. If a staged rollout is not possible, a new major schema version and gated deployment -are required. A version bump alone does not make old workers or clients compatible. Required tests -include paused HITL resumes, old/new polling, repeated conversion, a new request after rollback, -polling after transcript pruning, Python/.NET round-trips and unknown-data preservation. +old transcript. In isolated mode, rolling back the old deployment does not make it a valid reader or +writer for the v2 destination. A version bump alone proves neither state nor orchestration-history +compatibility. Required tests include each mode's gates, paused HITL behavior, polling, tooling, +repeated conversion, new requests after supported rollback, polling after transcript pruning, +Python/.NET read/write round-trips and unknown-data preservation. ## Consequences @@ -577,7 +744,7 @@ polling after transcript pruning, Python/.NET round-trips and unknown-data prese durable, external and service-owned history, without a required external message mirror. - Eager pruning and pressure eviction can be enabled separately. Both remain non-deleting by default, so an unconfigured session can still reach its backend limit. -- Pruning cannot change an original result or erase completion evidence. Those protected records +- Pruning cannot change an original result or erase completion evidence. Completion tombstones accumulate throughout an active entity's lifetime and can prevent further writes even when transcript retention is enabled. Bounded completion bookkeeping remains a durable follow-up. - Custom selection can require growing sets of ingestion receipts. That control-state cost belongs @@ -587,34 +754,48 @@ polling after transcript pruning, Python/.NET round-trips and unknown-data prese - Local slices commit together, but external writes and uncommitted tool effects can repeat after failure. Optional retry-safe history adapters do not make the entity and store transactional or guarantee exactly-once model/tool effects. -- The state transition requires compatible workers and polling clients even if the transcript - retains its field name. In-flight workflows and supported rollback are release requirements. +- Shared transition requires proven readers, writers, clients, tooling and replay compatibility, + even if the transcript retains its field name. Isolated rollout keeps old workflows on the old + engine and hub, with explicit migration rather than implied in-flight compatibility. - The Python integration uses a session-buffer bridge until core exposes provider lifecycle APIs. - The evaluated .NET compaction-state format requires additional work for eager-pruning parity. + .NET eager-pruning parity needs safe exclusion, summary, cadence and decorator handling. Pressure + retention is independent, and the proposed .NET ownership policy does not constrain core APIs. ## Validation Requirements The following are acceptance requirements for the proposed implementation, not claims about the -existing prototype's coverage. +existing prototype's coverage. Published unit, scripted-provider and live text evidence is scoped +in [Prototype Evidence](#prototype-evidence). It does not establish the missing cancellation, live +inline-file/multimodal pressure, OpenTelemetry or combined error/commit/poller cases below. 1. **Provider-independent execution.** Test success, errors, polling, repeated correlations and cold reloads with durable, external, service-owned and legacy agents. Verify one transcript append path, provider storage choices, stable IDs, annotation round-trips and summary ordering. + Separate Python's inactive-external-primary load/store suppression, including per-call hooks, + from ordinary core behavior. Verify store-only sinks and .NET's separate ownership policy. 2. **Delivery lifetime.** Original mailbox responses and references must survive transcript annotation, summary insertion, pruning and clearing. Test delivery expiry, completion receipts, - and a duplicate request after its transcript response was removed. + and a duplicate request after its transcript response was removed. Cover every outcome in the + delivery table, explicit null/falsey/non-text values, typed JSON metadata, unknown optional raw + data and pending approvals. Missing offloaded delivery data must not become a successful empty + result. 3. **Retention matrix.** Exercise all four combinations of eager pruning and pressure budget, - explicit provider overrides, `"backend_limit"`, custom watermarks and unresolved host limits. + supported runtime-specific provider overrides, Python's optional `"backend_limit"`, custom + watermarks and unresolved host limits. Require aligned non-deleting defaults in both runtimes. Test system messages, newest exchanges, atomic tool/reasoning groups, metadata-only floors, growing completion/ingestion receipts, oversized results, unreachable targets and truncation - evidence. + evidence. Add live inline-file and multimodal pressure cases, not only synthetic payloads or text. + Pair persisted readback with subsequent model input. Check bounded OpenTelemetry fields and + distinguish planned, staged and host-confirmed commits without payload or high-cardinality labels. 4. **Session continuity.** Restore provider types, pending approvals and service conversation IDs - on committed success/error paths. Cold-reload through `store=True -> False -> True` with a - valid saved service ID. The client-owned run must ignore that ID in model calls and history + on committed success/error paths. In Python, cold-reload through `store=True -> False -> True` + with a valid saved service ID. The client-owned run must ignore it in model calls and history hooks; the later service-owned invocation must receive the preserved ID. Neither transcript may be synthesized from mailbox results or merged with the other. Also cover transitions without a service ID, current-input preservation and contentless legacy records. Exercise bounded - matching-error retries and immediate failure on others. + matching-error retries only before streaming/tool/session progress, and immediate failure on + other errors. Do not infer provider-hook retry safety. For .NET, honor supported overrides and + reject unsupported transitions rather than silently replacing core's effective choice. 5. **Workflow inputs.** Test cycles, fan-out, fan-in, replay, delivery receipts and eviction. A custom projection `[1, 3]` followed by `[2, 4]` must deliver both `2` and `4` on the second visit at the sender and receiver, including after cold reload and transcript eviction. Assert @@ -622,24 +803,38 @@ existing prototype's coverage. and the selected order is preserved. Distinguish repeated context under a new correlation from repeated request delivery. Include custom, missing and fully repeated message IDs, plus deterministic non-monotonic projection and any cursor fast path's equivalence to exact tracking. -6. **Registration.** Verify no-primary and sink-only injection, in-memory replacement, preserved - `source_id`/`skip_excluded`, explicit `prune_excluded` precedence, external-provider preservation - and rejection of multiple load-enabled primaries. -7. **State transition.** Test legacy reading, idempotent conversion, old/new polling, paused - human-in-the-loop (HITL) resumes and new requests after rollback. Include partially altered - legacy results, expiry grace, Python/.NET rewrites and unknown-data preservation. Test scalar - ingestion state with missing delivery evidence rather than assuming every earlier position was - delivered when converting to exact receipts. + Compare the exact logical selected input separately from the wire delta, including full typed + messages, changed-content revisions and synthesized occurrences. Do not equate receipt presence + with payload availability after pruning or allow transport IDs to rewrite application IDs. +6. **Python registration.** Verify no-primary and sink-only injection, exact built-in in-memory + replacement, custom subclass preservation, `source_id`/`skip_excluded`, explicit + `prune_excluded` precedence, external-provider preservation + and rejection of multiple load-enabled primaries. Audit sinks need nonempty unique IDs and + store-only configuration. Separately test .NET's singular history provider, `AIContextProviders` + and decorated providers without imposing Python's registration model. +7. **State transition.** Test both explicit deployment modes. Shared rollout must prove readers, + writers, SDK/HTTP polling, tooling, paused HITL replay and rollback against orchestration history. + Isolated rollout must validate deployment/routing separation and reject old recorded workflow + starts in the new engine. An acknowledgement setting alone cannot detect mixed peers. + Test legacy read-only handling, protocol-2 new starts, destination-bound trusted imports, + idempotent conversion and full journals for scalar gaps. Include partially altered legacy results, + expiry grace, Python/.NET rewrites and unknown-data preservation. Version equality alone is not + a compatibility test. 8. **Failure boundaries.** Inject failures around local commit and external writes. Uncommitted - effects must not become protected completed operations. Verify the polling timeout/error - behavior when capacity prevents even an error-response commit. Include an ordinary append-only - provider whose write succeeds before a worker failure and can repeat on retry; do not claim - duplicate-free external storage for it. Retry-safe adapters, when added, require separate - failure-injection tests for their declared guarantees. + effects must not become protected completed operations. Cover caller wait cancellation, execution + cancellation and worker shutdown before/after commit, provider failures at each stage, actual + accepted-input receipts and uncertain commit acknowledgements. Combine provider error, failed + error-result persistence and poller behavior in one scenario, not only separate unit cases. + Verify direct failure versus polling timeout when even an error outcome cannot fit. Include an + ordinary append-only provider whose write succeeds before a worker failure and can repeat on + retry. Do not claim duplicate-free external storage for it. Retry-safe adapters, when added, + require separate failure-injection tests for their declared guarantees. Live scheduler-limit tests, offload validation and cross-language compaction parity remain required -as those capabilities are implemented. Any future LLM-based reducer also needs stable summary -identities and retry/idempotency tests. Reduced-budget prototype tests do not substitute for these. +as those capabilities are implemented. .NET eager-pruning tests must cover exclusion/summary state, +callback cadence and decorator paths before enabling `FollowCompaction`. Any future LLM-based +reducer also needs stable summary identities and retry/idempotency tests. Reduced-budget prototype +tests and live text runs do not substitute for the missing coverage. ## Dependencies and Follow-up Work @@ -648,8 +843,9 @@ assertion that later package versions retain every limitation. Revalidate each d the versions selected for its implementation PR. New follow-up issues will be filed after ADR approval. -Bounded completion bookkeeping and retry-safe external writes are durable-owned follow-ups. They -do not add mandatory capabilities to core history providers. +Bounded completion bookkeeping and retry-safe external writes are durable-owned follow-ups. +Provider lifecycle improvements also remain follow-up work. None is a universal prerequisite for +using ordinary core history providers with this initial integration. ### 1. Provider-owned store reduction @@ -705,15 +901,19 @@ duplicates messages. Omitting it loses exclusions and incremental summarization only included messages can cause `CompactionMessageIndex.Update()` to rebuild that state. Lightweight compaction metadata keyed by `MessageId` is the desired upstream representation. Until -that gap is resolved, pressure retention can operate independently, but Python's eager-pruning -integration does not establish .NET parity. +safe exclusion and summary handling is available, defer .NET `FollowCompaction`. Callback cadence +and decorated history-provider paths must also expose enough state to make deletion safe. This is +a capability gate, not a permanent Python-only policy. Pressure retention can operate independently, +and Python's durable-provider bridge does not grant external stores rewrite support. ### 5. Provider callback cadence -With `require_per_service_call_history_persistence=True`, history providers run per model call +In Python, `require_per_service_call_history_persistence=True` runs history providers per model call while compaction remains once per run. Annotations made after the final history flush can therefore be persisted later than intended. The evaluated implementation enables this on `HarnessAgent`. -Preserve the configured cadence and test the final flush ordering. +Preserve the configured cadence and test the final flush ordering, including the proposed +inactive-external-primary suppression without suppressing explicit store-only sinks. Evaluate .NET +callback and decorator behavior independently rather than assuming Python's hook ordering applies. ### 6. Host payload-store access @@ -722,9 +922,10 @@ The inspected `2.0.0b1`/`2.0.0b2` previews require Python 3.13+ and `durabletask their `DurableFunctionsWorker` and `DurableFunctionsClient` do not expose the base `payload_store` parameter. The direct durabletask path does. Exposing that parameter remains a host dependency. -Backend metadata for `max_state_bytes="backend_limit"` is also needed where the host cannot identify -a hard limit. Do not silently infer an unlimited or offloaded budget in that case. An explicit byte -budget remains the portable option. +The optional Python `max_state_bytes="backend_limit"` convenience is limited to a known +DTS/Scheduler host limit. Reject unresolved limits rather than inferring an unlimited or offloaded +budget. It does not account for all transport overhead or guarantee acceptance. `None` and an +explicit positive byte budget remain the portable choices. ### 7. Bounded completion bookkeeping @@ -756,7 +957,8 @@ capability does not block their initial integration. ### Release gates and excluded scope -- State and response-consumer compatibility must precede revised writes, following +- Shared-mode reader/writer, SDK/HTTP/tooling, replay and rollback compatibility must precede + revised writes. The prototype instead starts isolated with separate hubs and clients, following [State Evolution and Compatibility](#state-evolution-and-compatibility). - Moving arbitrary custom projection into an activity and exposing public context accessors are tracked in [#79][issue79]. The purity contract remains in force meanwhile. @@ -767,34 +969,58 @@ capability does not block their initial integration. ## Prototype Evidence -The implementation reference is [Python prototype PR #59][prototype], at `c4582a1`. The observations -below were recorded during its development. They illustrate design trade-offs, not guaranteed size -ratios, performance targets or validation of the revised execution/delivery layout. - -### Implementation status and compatibility trade-off - -The prototype retains the combined `conversationHistory` representation. `AgentEntity` appends -requests and responses, and `DurableHistoryProvider.save_messages()` is a no-op. External and -service-owned request content is cleared after invocation, leaving metadata-only message records. -Its replay converters already skip records with no replayable content. - -Retaining that layout avoided relocating transcripts when new workers resumed existing sessions or -paused workflows. The proposed contract separates execution and history ownership without requiring -that relocation. Mailbox and receipt changes still need the state and response-lookup transition -specified above. Empty per-message records are not a universal execution requirement, although the -prototype's custom-ID deduplication fallback consumes some retained IDs. - -The prototype demonstrates provider substitution, ID/annotation round-trips, synthetic summary -insertion, reconciliation, session persistence, workflow projection and target-side deduplication. -Its retention tests cover the original `keep_all`, `auto` and `follow_compaction` modes, not the -independent controls specified here. Twenty-turn tests use a reduced budget with `keep_all` as the -control. Scheduler integration covers persisted metadata, external-provider session identity, -schema conformance, downstream workflow context and a Redis-owned conversation. This does not -validate the proposed mailbox/receipt layout, exact delivery tracking, source-side delta transport -or `store=True -> False -> True` service-branch isolation. The target's `ingestedPositions` remains -a per-producer maximum, and the Redis sample appends without a retry receipt. - -### Recorded observations +### Published prototype evidence + +The published reference is [Python prototype PR #59][prototype] at [5b872d1][prototype-head]. See +the [test commit 1aac4fd][prototype-tests], [documentation commit 3ad9][prototype-docs] and +[samples validation record][prototype-validation] for the recorded unit, scripted-provider and +live text evidence. These evidence classes are distinct, not interchangeable release guarantees. + +The published prototype covers the revised execution/delivery separation, Python ownership and +workflow delta paths under its isolated-v2 deployment contract. Its documented evidence does not +establish shared Python/.NET rollout compatibility or make its private APIs and schema the final +common design. Local follow-up commit `9b4550d` fixes integration child environments and rejects the +known incompatible completion containers. It is not part of the published reference above. + +That follow-up passed 3,303 unit tests with zero skips in each Python 3.13/core 1.16, +Python 3.13/core 1.13 and Python 3.10/core 1.16 run. Package-only live suites passed 42 direct and +43 Functions tests with no parent deployment-mode setting or ancestor fixture. All 44 new cases +pass. Old-code probes fail 31 state cases and all 12 launcher cases, while the valid native-state +control still passes. Lint, typing, offline lock checks and both package builds also passed. +These are local results, not remote CI or cross-runtime acceptance. + +Cancellation boundaries, live inline-file/multimodal pressure, OpenTelemetry and the combined +provider-error/failed-commit/poller scenario remain acceptance work. The stricter retry and +observability requirements above are not claims that those cases have already been demonstrated. +Exact Pydantic 2.11 runtime validation remains unverified because artifact downloads were blocked. + +### Historical implementation at c4582a1 + +The remaining observations are strictly historical, recorded at `c4582a1`. They illustrate design +trade-offs, not current implementation status, guaranteed size ratios or performance targets. + +That prototype retained the combined `conversationHistory` representation. `AgentEntity` appended +requests and responses, and `DurableHistoryProvider.save_messages()` was a no-op. External and +service-owned request content was cleared after invocation, leaving metadata-only message records. +Its replay converters skipped records with no replayable content. + +Retaining that layout avoided transcript relocation, but did not prove compatibility for new +entry kinds, response lookup or paused-workflow replay. The proposed contract separates execution +and history ownership without requiring relocation. Mailbox and receipt changes follow the explicit +deployment modes above. Empty per-message records are not a universal execution requirement, +although the historical custom-ID fallback consumed some retained IDs. + +At that historical revision, tests demonstrated provider substitution, ID/annotation round-trips, +synthetic summaries, reconciliation, session persistence, projection and target-side deduplication. +Its retention tests covered the original `keep_all`, `auto` and `follow_compaction` modes, not the +independent controls specified here. Twenty-turn tests used a reduced budget with `keep_all` as the +control. Scheduler integration covered persisted metadata, external-provider session identity, +schema conformance, downstream workflow context and a Redis-owned conversation. That evidence did +not validate the mailbox/receipt layout, exact delivery tracking, source-side delta transport or +`store=True -> False -> True` service-branch isolation. The target's `ingestedPositions` was a +per-producer maximum, and the Redis sample appended without a retry receipt. + +### Historical recorded observations | Scenario | Observation | Design implication | | --- | --- | --- | @@ -804,14 +1030,14 @@ a per-producer maximum, and the Redis sample appends without a retry receipt. | Store-side strategy with in-memory versus file history | 11 exclusions and 4 summaries with in-memory history, none with the evaluated `FileHistoryProvider` | Session-buffer mutation does not rewrite an arbitrary external store | | Serialization of a 1 MB prototype state | Approximately 8 ms in the development measurement | Measure overhead during implementation validation, not as a latency guarantee | -The state-size observations used the prototype's combined layout. Mailbox payloads, receipts and -transition data will change that accounting. The recorded timings do not specify a portable hardware -baseline, and are not release acceptance thresholds. +The state-size observations used the historical combined layout, not today's mailbox, receipt and +transition accounting. The recorded timings do not specify a portable hardware baseline and are not +release acceptance thresholds. -### Complete workflow projection sizes +### Historical complete workflow projection sizes -These are serialized bytes for complete projections before target-side deduplication, not measured -delta-transport results. +These `c4582a1` measurements are serialized bytes for complete projections before target-side +deduplication, not measured delta-transport results. | Turns | `full` | `last_agent` | `custom`, last 4 messages | | ---: | ---: | ---: | ---: | @@ -822,9 +1048,9 @@ delta-transport results. At 800 turns the complete `full` projection was 64.4% of the 1 MB limit and `last_agent` about 0.1%. Reducing context can help when workflow semantics allow it, but is not a general replacement for -avoiding repeated prefixes. The delta implementation requires its own tests and measurements. +avoiding repeated prefixes. These historical measurements say nothing about current delta sizes. -### Service conversation visibility +### Historical service conversation visibility Early Azure OpenAI streaming probes observed returned response IDs that were not immediately readable, affecting roughly half of sampled streamed responses versus none of the non-streamed @@ -833,9 +1059,10 @@ These are observations of the service during development, not a claim that the o persists in every deployment. Retaining both sides locally for full-transcript recovery roughly doubled stored state in an -eight-turn service-backed comparison. The prototype removed that fallback and retained bounded -same-request retry. Expired IDs remain failures, consistent with the decision not to maintain a -second transcript as automatic recovery insurance. +eight-turn service-backed comparison. That prototype removed the fallback and retained bounded +same-request retry. Those probes do not establish retry safety after partial progress or repeated +provider hooks. Expired IDs remain failures, consistent with the decision not to maintain a second +transcript as automatic recovery insurance. ## References @@ -845,7 +1072,12 @@ second transcript as automatic recovery insurance. - [#5, external durable-agent conversation storage][issue5] - [#10, automatic session cleanup][issue10] - [#79, workflow context-filter replay][issue79] +- [ADR PR #88][adr-pr] - [Python prototype PR #59][prototype] +- [Published prototype head 5b872d1][prototype-head] +- [Prototype test commit 1aac4fd][prototype-tests] +- [Prototype documentation commit 3ad9][prototype-docs] +- [Prototype samples validation record][prototype-validation] [adr0019]: https://github.com/microsoft/agent-framework/blob/main/docs/decisions/0019-python-context-compaction-strategy.md [offload]: https://learn.microsoft.com/azure/durable-task/scheduler/durable-task-scheduler-large-payloads @@ -854,3 +1086,9 @@ second transcript as automatic recovery insurance. [issue10]: https://github.com/microsoft/agent-framework-durable-extension/issues/10 [issue79]: https://github.com/microsoft/agent-framework-durable-extension/issues/79 [prototype]: https://github.com/microsoft/agent-framework-durable-extension/pull/59 +[adr-pr]: https://github.com/microsoft/agent-framework-durable-extension/pull/88 +[schema-pr]: https://github.com/microsoft/agent-framework-durable-extension/pull/92 +[prototype-head]: https://github.com/microsoft/agent-framework-durable-extension/commit/5b872d1 +[prototype-tests]: https://github.com/microsoft/agent-framework-durable-extension/commit/1aac4fd +[prototype-docs]: https://github.com/microsoft/agent-framework-durable-extension/commit/3ad9 +[prototype-validation]: https://github.com/microsoft/agent-framework-durable-extension/blob/5b872d1/python/samples/README.md#prototype-validation From 2a9a8dd3de5260e75b74e345c7a4807028090bf8 Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Thu, 10 Sep 2026 14:39:34 -0500 Subject: [PATCH 11/13] docs: distinguish tested retention behavior from remaining coverage --- .../0032-durable-thread-compaction.md | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index 761e3ac..e6af647 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -764,9 +764,9 @@ Python/.NET read/write round-trips and unknown-data preservation. ## Validation Requirements The following are acceptance requirements for the proposed implementation, not claims about the -existing prototype's coverage. Published unit, scripted-provider and live text evidence is scoped -in [Prototype Evidence](#prototype-evidence). It does not establish the missing cancellation, live -inline-file/multimodal pressure, OpenTelemetry or combined error/commit/poller cases below. +existing prototype's coverage. This checklist includes requirements already covered by tests, not +just outstanding work. The coverage table in [Prototype Evidence](#prototype-evidence) separates +tested behavior, remaining validation, missing instrumentation and explicit follow-up capabilities. 1. **Provider-independent execution.** Test success, errors, polling, repeated correlations and cold reloads with durable, external, service-owned and legacy agents. Verify one transcript @@ -971,7 +971,7 @@ capability does not block their initial integration. ### Published prototype evidence -The published reference is [Python prototype PR #59][prototype] at [5b872d1][prototype-head]. See +The published reference is [Python prototype PR #59][prototype] at [9b4550d][prototype-head]. See the [test commit 1aac4fd][prototype-tests], [documentation commit 3ad9][prototype-docs] and [samples validation record][prototype-validation] for the recorded unit, scripted-provider and live text evidence. These evidence classes are distinct, not interchangeable release guarantees. @@ -979,8 +979,8 @@ live text evidence. These evidence classes are distinct, not interchangeable rel The published prototype covers the revised execution/delivery separation, Python ownership and workflow delta paths under its isolated-v2 deployment contract. Its documented evidence does not establish shared Python/.NET rollout compatibility or make its private APIs and schema the final -common design. Local follow-up commit `9b4550d` fixes integration child environments and rejects the -known incompatible completion containers. It is not part of the published reference above. +common design. The published follow-up `9b4550d` fixes integration child environments and rejects +the known incompatible completion containers without defining the final shared schema. That follow-up passed 3,303 unit tests with zero skips in each Python 3.13/core 1.16, Python 3.13/core 1.13 and Python 3.10/core 1.16 run. Package-only live suites passed 42 direct and @@ -989,9 +989,19 @@ pass. Old-code probes fail 31 state cases and all 12 launcher cases, while the v control still passes. Lint, typing, offline lock checks and both package builds also passed. These are local results, not remote CI or cross-runtime acceptance. -Cancellation boundaries, live inline-file/multimodal pressure, OpenTelemetry and the combined -provider-error/failed-commit/poller scenario remain acceptance work. The stricter retry and -observability requirements above are not claims that those cases have already been demonstrated. +| Area | Evidence and remaining work | +| --- | --- | +| Large tool arguments/results and atomic pressure eviction | Existing tests check tool-only byte accounting, the low watermark and the smallest atomic prefix for mixed Unicode/tool payloads. This is covered, not a deferred feature. | +| Newest exchange or delivery/control data cannot fit | Existing tests assert capacity failure without deleting the protected exchange or prior state, including a mailbox or receipt that alone exceeds the budget. | +| Media and file content | Schema/JSON cold-round-trip tests preserve inline data, file references and mixed binary/text tool results. Retention pressure followed by cold reload and exact subsequent model-input checks still needs combined coverage. Live suites are text-based. | +| Failures and result delivery | Existing tests cover provider/model errors, final-flush and write rollback, cached-state restoration, committed error delivery and bounded polling timeout separately. Cancellation/worker-stop scenarios and the combined provider-error, failed error-result commit and poller path remain validation gaps. | +| Retention observability | Persisted truncation evidence and a Python warning log exist. Retention-specific OpenTelemetry instruments, bounded attributes and planned/staged/confirmed-commit assertions are not implemented or validated. | +| Explicit follow-up capabilities | Bounded completion bookkeeping, optional retry-safe external writes and provider lifecycle APIs remain follow-ups. .NET eager pruning remains gated on safe exclusion, summary, cadence and decorator support. | + +Missing combined tests and instrumentation are work needed for the proposed contract, not evidence +that those capabilities require a new design or permission to defer them. Completing this ADR does +not complete their implementation or validation. The local 525-test rerun covering the existing +retention, fidelity, execution and delivery tests passed without adding new cases. Exact Pydantic 2.11 runtime validation remains unverified because artifact downloads were blocked. ### Historical implementation at c4582a1 @@ -1074,7 +1084,7 @@ transcript as automatic recovery insurance. - [#79, workflow context-filter replay][issue79] - [ADR PR #88][adr-pr] - [Python prototype PR #59][prototype] -- [Published prototype head 5b872d1][prototype-head] +- [Published prototype head 9b4550d][prototype-head] - [Prototype test commit 1aac4fd][prototype-tests] - [Prototype documentation commit 3ad9][prototype-docs] - [Prototype samples validation record][prototype-validation] @@ -1088,7 +1098,7 @@ transcript as automatic recovery insurance. [prototype]: https://github.com/microsoft/agent-framework-durable-extension/pull/59 [adr-pr]: https://github.com/microsoft/agent-framework-durable-extension/pull/88 [schema-pr]: https://github.com/microsoft/agent-framework-durable-extension/pull/92 -[prototype-head]: https://github.com/microsoft/agent-framework-durable-extension/commit/5b872d1 +[prototype-head]: https://github.com/microsoft/agent-framework-durable-extension/commit/9b4550d [prototype-tests]: https://github.com/microsoft/agent-framework-durable-extension/commit/1aac4fd [prototype-docs]: https://github.com/microsoft/agent-framework-durable-extension/commit/3ad9 -[prototype-validation]: https://github.com/microsoft/agent-framework-durable-extension/blob/5b872d1/python/samples/README.md#prototype-validation +[prototype-validation]: https://github.com/microsoft/agent-framework-durable-extension/blob/9b4550d/python/samples/README.md#prototype-validation From 2517bc254d51767cc3b3ecccc663e9c5b450abed Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Fri, 11 Sep 2026 14:56:58 -0500 Subject: [PATCH 12/13] docs: retain invocation outcomes after delivery expiry --- .../0032-durable-thread-compaction.md | 119 ++++++++++++------ 1 file changed, 79 insertions(+), 40 deletions(-) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index e6af647..5311000 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -18,7 +18,8 @@ delivery independent of transcript ownership. ### Shared correctness invariants - The entity owns request correlation, completion, original result delivery and duplicate-request - suppression for every history configuration. + suppression for every history configuration. Revised completion receipts retain the invocation + outcome after delivery payload expiry. - The selected history owner supplies the transcript. Durable-owned history remains entity-local. External and service-owned history does not require an entity-side message mirror. - The outgoing workflow conversation preserves the full logical selection, separately from the @@ -42,7 +43,7 @@ Shared invariants do not require identical core APIs or the prototype's private | Ownership policy | Per-run `store`, including `True -> False -> True` | Initially session-stable proposal, not a core API limit. Honor supported overrides, reject unsupported transitions. | | Eager pruning | `follow_compaction` on supported `DurableHistoryProvider`, not external stores | Defer `FollowCompaction` pending safe exclusion, summary, cadence and decorator handling. | | Pressure retention | Independent of compaction | Independent of compaction. `Auto` denotes pressure behavior, not automatic core compaction. | -| Defaults and budget | `keep_all` and `None` by default; an explicit positive byte budget enables pressure eviction | Require aligned non-deleting defaults and equivalent explicit-budget semantics. Do not assume they have shipped. | +| Defaults and budget | `keep_all` and `None` by default. An explicit positive byte budget enables pressure eviction. | Require aligned non-deleting defaults and equivalent explicit-budget semantics. Do not assume they have shipped. | Architectural decisions remain in [ADR PR #88][adr-pr]. Coverage and limitations of the published Python prototype are recorded in [Prototype Evidence](#prototype-evidence), separately from the @@ -196,7 +197,7 @@ relocating `conversationHistory`. ```mermaid flowchart LR ENTITY["One entity / one session
One total size budget"] - ENTITY --> EXEC["Execution and delivery, every owner
Request-level bookkeeping
responseMailbox + completedCorrelations"] + ENTITY --> EXEC["Execution and delivery, every owner
responseMailbox + completedCorrelations
Outcome retained after payload expiry"] ENTITY --> CONTROL["Session and workflow control, as needed
session + ingestion receipts
Occurrence and revision bookkeeping"] ENTITY --> HISTORY["Local transcript, when used
conversationHistory
Messages, IDs, annotations + truncation"] ``` @@ -204,7 +205,7 @@ flowchart LR History providers own transcript read, append and reconciliation behavior. The entity runtime physically commits all entity-local slices at the operation boundary. One owner must append each transcript input and output, preserving provider storage choices without competing writers. This -does not guarantee a single external append across retries; see +does not guarantee a single external append across retries. See [failure boundaries](#commit-and-failure-boundaries). The prototype's append ownership is described in [Prototype Evidence](#prototype-evidence). @@ -237,7 +238,7 @@ list. Implicit history follows core's provider ordering. Preserve custom durable state while excluding only its derived message buffer and position index. A custom in-memory subclass's session transcript remains part of the protected state budget, not the durable transcript's eviction policy. Substitution does not import content accumulated in an in-memory -provider before registration; that import scenario is outside the persisted-state upgrade contract. +provider before registration. That import scenario is outside the persisted-state upgrade contract. ```mermaid flowchart TB @@ -286,11 +287,24 @@ core's effective choice. Reject unsupported overrides or transitions explicitly rather than silently forcing the saved owner over that choice. Do not infer Python's injection or inactive-primary storage behavior from this policy. +### Configuration identity + +Stable provider/configuration identity is distinct from effective per-run ownership. An optional, +runtime-owned versioned profile is the proposed initial representation, not a mandatory shared +binding object. The exact representation remains under review in [schema PR #92][schema-pr]. +Compatible writers must preserve such state. A host must validate any profile it relies on for +restoration or execution, rather than guessing an owner from opaque session data. A configuration +descriptor does not require its facility to own every run or change a runtime's supported transitions. + ## Execution, Delivery and Session Lifecycle The execution path is the same for every history owner. Workflow projection and delta selection occur before the entity receives the request. +The outcome-bearing contract below applies to revised receipts. Older timestamp-only receipts +still suppress duplicate execution and follow the +[transition policy](#conversion-requirements-within-the-chosen-mode) when their outcome is unknown. + ```mermaid sequenceDiagram participant C as Caller / workflow @@ -298,7 +312,7 @@ sequenceDiagram participant A as Agent + selected history owner C->>E: Request and correlation ID alt Completion already recorded - E-->>C: Original result or already-completed status + E-->>C: Original result, or unavailable + recorded outcome else New request E->>E: Restore session and ingestion state E->>A: Current input and session @@ -331,37 +345,46 @@ so expiry bounds payload retention. An acknowledgement operation is a possible l | --- | --- | | Committed success | Return the original result, including an explicit null, falsey or non-text value. Completion does not depend on nonempty text. | | Committed error | Return the recorded error and completion evidence. Do not reinterpret it as a successful empty response. | -| Expired delivery | Return already-completed/expired status from the receipt. Do not invoke again or resurrect a transcript response. | +| Expired delivery | Return completed-but-result-unavailable with the retained `succeeded` or `failed` outcome. Do not return the expired payload, invoke again or resurrect a transcript response. | | No result | Absence alone establishes neither acceptance, completion nor failure. Poll within the caller's deadline or report unresolved status. | | Accepted | Report only actual acceptance/dispatch evidence. Acceptance is not final completion. | | Pending approval | Deliver the approval request and persist pending state. This does not mean the guarded tool action executed successfully. | +For revised writes, record the invocation outcome and completion timestamp in the receipt at the +same local commit as the original result. Those facts are immutable and survive payload expiry. +Expiry changes result availability, not the outcome of the correlated invocation. That outcome +does not imply completion of an enclosing workflow or execution of a pending approval-gated action. +Older receipts without an outcome require the explicit +[transition policy](#conversion-requirements-within-the-chosen-mode). + Define a JSON projection of the supported response fields, including messages/content, message and response IDs, author/agent metadata, original creation time, usage, finish reason, provider continuation and additional properties. Preserve structured values separately from text, including the distinction between an absent value and explicit `null`, `false`, `0` or an empty container. -Property presence or an explicit marker can encode that distinction; this ADR does not mandate a +Property presence or an explicit marker can encode that distinction. This ADR does not mandate a new flag. Do not persist opaque SDK objects or Python/.NET response-format classes as the contract. Preserve supported tool and multimodal content without flattening it. Exact field names and discriminators belong to schema review, not the prototype's private shape. Preserve unknown optional JSON data safely for round-trips, without dynamically loading types or executing content merely by reading it. Opaque SDK representations are outside this guarantee. -The original payload and its metadata need not remain available after delivery expiry. Referenced -resource lifetime is separate from result lifetime; a retained URI does not guarantee the resource -still exists. Failure to read an offloaded delivery payload must not become a successful empty -result. An approval response may complete one request's delivery while the pending action still +The original payload and its detailed response metadata need not remain available after delivery +expiry. The completion timestamp and invocation outcome remain in the receipt. Referenced resource +lifetime is separate from result lifetime. A retained URI does not guarantee the resource still +exists. Failure to read an offloaded delivery payload must not become a successful empty result. +An approval response may complete one request's delivery while the pending action still needs an explicit resume under a new correlation. A recovered tool-role error is not automatically a terminal invocation failure. At delivery expiry, stop returning the payload even if physical cleanup is lazy. Remove the -payload or reference during a subsequent operation or explicit maintenance, but retain a -`completedCorrelations` tombstone until the entity is deleted. Idle physical cleanup requires a -host/application-owned schedule. The Python prototype exposes an `expire_responses` entity operation -rather than an implicit idle timer. A duplicate correlation returns its retained result or an -already-completed status, not another agent invocation. Transcript compaction and clearing must not -alter results, remove live mailbox obligations, or erase completion receipts. Runtime-error results -and receipts are not model context. +payload or reference during a subsequent operation or explicit maintenance, but retain the +completion timestamp and outcome in a `completedCorrelations` tombstone until the entity is deleted. +Idle physical cleanup requires a host/application-owned schedule. The Python prototype exposes an +`expire_responses` entity operation rather than an implicit idle timer. A duplicate correlation +returns its retained result or the completed-but-unavailable status and recorded outcome, not +another agent invocation. Transcript compaction and clearing must not alter results, remove live +mailbox obligations, or erase completion receipts. Runtime-error results and receipts are not model +context. An indefinitely active entity accumulates tombstones without a fixed bound. They can eventually fill the non-evictable floor even after transcript pruning. This long-session limitation requires @@ -403,7 +426,7 @@ retry may repeat those calls and side effects. The design does not checkpoint be | --- | --- | | Caller stops waiting or cancels polling | Stop that wait only. This is not execution cancellation, and the entity may still commit. | | Worker shutdown or execution cancellation before commit | Discard uncommitted local changes. Do not invent completion. External writes, model calls or tools may already have succeeded. | -| Cancellation after confirmed commit | The request is already completed. Return its recorded outcome rather than undoing it or invoking again. | +| Cancellation after confirmed commit | The request is already completed. Return its retained result, or unavailable status with the recorded outcome after expiry. Do not undo it or invoke again. | | Provider failure during load, invocation or store | Stage a runtime-error outcome if possible, with actual accepted-input receipts and resulting session state. Do not fabricate acceptance for rejected input. | | Final reconciliation, session serialization or a known pre-commit failure | Leave the last committed local state intact. Do not return staged completion as a committed outcome. | | Error outcome cannot be persisted | Use the direct operation failure channel where available. State-polling callers may time out without a durable error result. | @@ -435,7 +458,7 @@ been no streaming progress, tool execution or session/continuation advance. Stop different error or any such progress. If matching refusals continue, fail the turn. Never replay a partially consumed stream or restart a tool loop after progress. Retrying a whole -invocation before observable progress can still repeat provider hooks; a matching refusal does not +invocation before observable progress can still repeat provider hooks. A matching refusal does not prove those hooks were side-effect-free. Preserve configured cadence and do not claim external writes are retry-safe without the provider's own guarantee. @@ -649,8 +672,8 @@ major version. The evaluated legacy readers accept only `request` and `response` polymorphic deserialization rejects unknown `$type` values, and Python's fallback uses the same limited enum. New entry kinds or delivery state therefore need explicit deployment gates. -[Schema PR #92][schema-pr] is a separate review draft, not final format agreement or a replacement -for the architectural decisions in PR #88. +[Schema PR #92][schema-pr] is a separate contract proposal under review, not final format agreement +or a replacement for the architectural decisions in PR #88. ### Mode 1. Shared deployment, conditional reader-first rollout @@ -715,8 +738,15 @@ that transition, not just a version gate. Do not infer a fully delivered prefix receipts solely from the pruned transcript. In isolated mode, apply these checks at explicit import, not as implicit permission for the new engine to resume an old orchestration. -Only recorded outcomes justify backfilled completion receipts. If old retention already removed an -outcome, migration cannot reconstruct it or claim that duplicate suppression covered that request. +Only authoritative recorded evidence justifies backfilling a completion receipt or invocation +outcome. An older timestamp-only receipt still proves completion and must continue to suppress +duplicate execution. If no retained result or other trusted evidence establishes its outcome, +migration must not assign `succeeded` or `failed`, erase the receipt or rerun the request to recover +that fact. Keep such state on a compatible deployment or use an explicitly agreed legacy handling +policy that preserves completion without inventing an outcome. A target format requiring a known +outcome must reject an import that lacks this evidence. Where both result and completion evidence +are gone, migration cannot reconstruct either or claim prior duplicate suppression. + An existing response may itself have been partially pruned or annotated. Preserve its available payload and completion evidence, without claiming to reconstruct the original full response. Immutable original-result guarantees apply to revised writes, not retroactively to changed data. @@ -744,7 +774,8 @@ Python/.NET read/write round-trips and unknown-data preservation. durable, external and service-owned history, without a required external message mirror. - Eager pruning and pressure eviction can be enabled separately. Both remain non-deleting by default, so an unconfigured session can still reach its backend limit. -- Pruning cannot change an original result or erase completion evidence. Completion tombstones +- Pruning cannot change an original result or erase completion evidence. Revised receipts retain + the invocation outcome after delivery expiry without retaining the payload. Completion tombstones accumulate throughout an active entity's lifetime and can prevent further writes even when transcript retention is enabled. Bounded completion bookkeeping remains a durable follow-up. - Custom selection can require growing sets of ingestion receipts. That control-state cost belongs @@ -771,14 +802,17 @@ tested behavior, remaining validation, missing instrumentation and explicit foll 1. **Provider-independent execution.** Test success, errors, polling, repeated correlations and cold reloads with durable, external, service-owned and legacy agents. Verify one transcript append path, provider storage choices, stable IDs, annotation round-trips and summary ordering. - Separate Python's inactive-external-primary load/store suppression, including per-call hooks, + Separate Python's inactive-external-primary load/store suppression, including per-call hooks, from ordinary core behavior. Verify store-only sinks and .NET's separate ownership policy. 2. **Delivery lifetime.** Original mailbox responses and references must survive transcript annotation, summary insertion, pruning and clearing. Test delivery expiry, completion receipts, - and a duplicate request after its transcript response was removed. Cover every outcome in the + and a duplicate request after its transcript response was removed. Verify both success and + failure at expiry before physical cleanup, after cleanup and after cold reload. The completion + timestamp must remain unchanged. Lookup must expose the retained outcome without returning + payloads or invoking again. Cover every outcome in the delivery table, explicit null/falsey/non-text values, typed JSON metadata, unknown optional raw - data and pending approvals. Missing offloaded delivery data must not become a successful empty - result. + data and pending approvals. Missing offloaded delivery data must not become a successful empty + result. 3. **Retention matrix.** Exercise all four combinations of eager pruning and pressure budget, supported runtime-specific provider overrides, Python's optional `"backend_limit"`, custom watermarks and unresolved host limits. Require aligned non-deleting defaults in both runtimes. @@ -790,7 +824,7 @@ tested behavior, remaining validation, missing instrumentation and explicit foll 4. **Session continuity.** Restore provider types, pending approvals and service conversation IDs on committed success/error paths. In Python, cold-reload through `store=True -> False -> True` with a valid saved service ID. The client-owned run must ignore it in model calls and history - hooks; the later service-owned invocation must receive the preserved ID. Neither transcript may + hooks. The later service-owned invocation must receive the preserved ID. Neither transcript may be synthesized from mailbox results or merged with the other. Also cover transitions without a service ID, current-input preservation and contentless legacy records. Exercise bounded matching-error retries only before streaming/tool/session progress, and immediate failure on @@ -808,18 +842,22 @@ tested behavior, remaining validation, missing instrumentation and explicit foll with payload availability after pruning or allow transport IDs to rewrite application IDs. 6. **Python registration.** Verify no-primary and sink-only injection, exact built-in in-memory replacement, custom subclass preservation, `source_id`/`skip_excluded`, explicit - `prune_excluded` precedence, external-provider preservation - and rejection of multiple load-enabled primaries. Audit sinks need nonempty unique IDs and - store-only configuration. Separately test .NET's singular history provider, `AIContextProviders` + `prune_excluded` precedence, external-provider preservation and rejection of multiple load-enabled + primaries. Audit sinks need nonempty unique IDs and store-only configuration. Separately test + .NET's singular history provider, `AIContextProviders` and decorated providers without imposing Python's registration model. 7. **State transition.** Test both explicit deployment modes. Shared rollout must prove readers, writers, SDK/HTTP polling, tooling, paused HITL replay and rollback against orchestration history. - Isolated rollout must validate deployment/routing separation and reject old recorded workflow - starts in the new engine. An acknowledgement setting alone cannot detect mixed peers. - Test legacy read-only handling, protocol-2 new starts, destination-bound trusted imports, + Isolated rollout must validate deployment/routing separation and reject old recorded workflow + starts in the new engine. An acknowledgement setting alone cannot detect mixed peers. + Test legacy read-only handling, protocol-2 new starts, destination-bound trusted imports, idempotent conversion and full journals for scalar gaps. Include partially altered legacy results, expiry grace, Python/.NET rewrites and unknown-data preservation. Version equality alone is not - a compatibility test. + a compatibility test. Include timestamp-only receipts with no recoverable outcome and verify + that migration neither invents an outcome nor loses completion evidence. A known-outcome target + must reject such imports without authoritative evidence. Agreed legacy-compatible handling must + preserve duplicate suppression after cold reload. Readers and rollback writers must preserve + the agreed outcome and lookup contract before revised writes are enabled. 8. **Failure boundaries.** Inject failures around local commit and external writes. Uncommitted effects must not become protected completed operations. Cover caller wait cancellation, execution cancellation and worker shutdown before/after commit, provider failures at each stage, actual @@ -933,7 +971,7 @@ Evaluate acknowledgement plus a defined redelivery window, compact sequence wate request protocol permits them, or offloaded completion receipts. Any bound must define what happens to a duplicate request after its receipt expires without silently allowing completed work to run again. Idle-session TTL does not bound an entity kept active by new requests. This work does not -block the initial implementation; until a replacement protocol is defined, tombstones remain until +block the initial implementation. Until a replacement protocol is defined, tombstones remain until entity deletion and their growth remains an explicit capacity limitation. ### 8. Retry-safe external history writes @@ -952,7 +990,7 @@ duplicate-free writes. Preserve provider callback cadence, including multiple ap An entity-only receipt, activity or outbox does not alone close the external-write/acknowledgement gap. The stronger guarantee requires backing-store cooperation, but not a universal core API change or an entity-side transcript mirror. It protects history appends, not repeated model calls or tool -effects. Existing providers remain supported with the documented possible-duplicate behavior; this +effects. Existing providers remain supported with the documented possible-duplicate behavior. This capability does not block their initial integration. ### Release gates and excluded scope @@ -991,6 +1029,7 @@ These are local results, not remote CI or cross-runtime acceptance. | Area | Evidence and remaining work | | --- | --- | +| Outcome after payload expiry | At `9b4550d`, Python receipts retain completion time but not the original success/failure outcome. Expired lookup returns a generic completed/expired status. The revised outcome-retention contract above requires a Python receipt/lookup update and tests, including handling of older receipts. | | Large tool arguments/results and atomic pressure eviction | Existing tests check tool-only byte accounting, the low watermark and the smallest atomic prefix for mixed Unicode/tool payloads. This is covered, not a deferred feature. | | Newest exchange or delivery/control data cannot fit | Existing tests assert capacity failure without deleting the protected exchange or prior state, including a mailbox or receipt that alone exceeds the budget. | | Media and file content | Schema/JSON cold-round-trip tests preserve inline data, file references and mixed binary/text tool results. Retention pressure followed by cold reload and exact subsequent model-input checks still needs combined coverage. Live suites are text-based. | From 6333d0a94d969bd4cdddc8a8c2ecef0a5ae0a42b Mon Sep 17 00:00:00 2001 From: Ahmed Muhsin Date: Fri, 11 Sep 2026 20:08:27 -0500 Subject: [PATCH 13/13] docs: record outcome and live retention validation --- .../0032-durable-thread-compaction.md | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/decisions/0032-durable-thread-compaction.md b/docs/decisions/0032-durable-thread-compaction.md index 5311000..6312443 100644 --- a/docs/decisions/0032-durable-thread-compaction.md +++ b/docs/decisions/0032-durable-thread-compaction.md @@ -486,6 +486,8 @@ supported, not identical API names in both runtimes. `None` and a positive integer are the portable budget choices. A known direct DTS limit is 1,048,576 bytes, but serialized entity bytes exclude transport framing and other host overhead. `"backend_limit"` is not a universal backend-discovery API or a guarantee that a write will fit. +The current local choice keeps it as a non-normative Python-only convenience, not part of the +portable contract or an assumption of shared-reviewer concurrence. Neither control enables the other. In Python, an explicitly pinned provider `prune_excluded` value takes precedence over registration retention. The matrix assumes a supported local pruning path @@ -1027,21 +1029,42 @@ pass. Old-code probes fail 31 state cases and all 12 launcher cases, while the v control still passes. Lint, typing, offline lock checks and both package builds also passed. These are local results, not remote CI or cross-runtime acceptance. +### Local follow-up, 2026-09-11 + +The following evidence covers the local follow-up after `9b4550d`, not that published baseline. +This ADR update follows published ADR commit `2517bc2`. Each Python 3.13/core 1.16, +Python 3.13/real cached core 1.13 and Python 3.10/core 1.16 unit run passed 3,427 tests with zero skips. +Direct live tests passed 45 in 354.65 seconds, and Functions passed 45 in 649.44 seconds. +The initial Functions run had 36 failures +and seven passes from Azurite rejecting Storage API `2026-02-06`. The rerun passed after setting +`--skipApiVersionCheck` on the local test emulator, without product changes for that failure. + | Area | Evidence and remaining work | | --- | --- | -| Outcome after payload expiry | At `9b4550d`, Python receipts retain completion time but not the original success/failure outcome. Expired lookup returns a generic completed/expired status. The revised outcome-retention contract above requires a Python receipt/lookup update and tests, including handling of older receipts. | +| Outcome after payload expiry | Local revised receipts retain `succeeded`/`failed` and completion time. Expired lookup exposes `durable_outcome`, including `unknown` for older receipts without trustworthy evidence, without rerunning completed work or changing original payloads. All 52 outcome cases pass, including formatted and unformatted acceptance-only regressions, plus eight added existing consumer parameterizations. The standalone SDK API is unchanged. Functions expired JSON exposes `outcome` and `agent_response.additional_properties.durable_outcome`, text uses `x-ms-durable-outcome`, and MCP errors include the outcome. | +| Legacy outcome transition | Independent original mailbox evidence can backfill outcomes before payload removal. A missing receipt uses mailbox `createdAt` for `completedAt`, not migration time. A possibly pruned transcript without an error cannot prove success. Entity `requireKnownOutcomes` and helper `require_known_outcomes` reject imports without known evidence when enabled. The default legacy-compatible path preserves unknown receipts and duplicate suppression. Fresh unknown-outcome completion recording raises before either delivery map changes. Legacy and fire-and-forget acceptance behavior remain intact. These are prototype semantics, not an agreed wire format. | | Large tool arguments/results and atomic pressure eviction | Existing tests check tool-only byte accounting, the low watermark and the smallest atomic prefix for mixed Unicode/tool payloads. This is covered, not a deferred feature. | | Newest exchange or delivery/control data cannot fit | Existing tests assert capacity failure without deleting the protected exchange or prior state, including a mailbox or receipt that alone exceeds the budget. | -| Media and file content | Schema/JSON cold-round-trip tests preserve inline data, file references and mixed binary/text tool results. Retention pressure followed by cold reload and exact subsequent model-input checks still needs combined coverage. Live suites are text-based. | -| Failures and result delivery | Existing tests cover provider/model errors, final-flush and write rollback, cached-state restoration, committed error delivery and bounded polling timeout separately. Cancellation/worker-stop scenarios and the combined provider-error, failed error-result commit and poller path remain validation gaps. | -| Retention observability | Persisted truncation evidence and a Python warning log exist. Retention-specific OpenTelemetry instruments, bounded attributes and planned/staged/confirmed-commit assertions are not implemented or validated. | +| Media and file content | All 30 local units pass, covering inline PNG, inline text files, image URIs, hosted files, mixed binary/text tool results and large tool payloads across four retention/budget policies, plus six protected-floor cases. They combine JSON reload, exact next model input, atomic groups and truncation/metric counts. Two live DTS cases and two live Functions/Azure Storage cases verify binary-heavy PNG/inline-file pressure, persisted readback and process restart at a reduced budget. Hosted-model media acceptance and actual scheduler-limit/offload behavior are not established by these tests. | +| Failures and result delivery | All 11 local cancellation/failure units and three Functions consumer units pass, including warm rollback, uncertain write acknowledgement, caller wait cancellation and combined provider failure, rejected error persistence and bounded polling. A third new live DTS case hard-kills before commit, observes repeated simulated external effects on retry, then kills after authoritative completion readback and verifies duplicates do not reinvoke. Graceful-shutdown-specific host behavior is not established by task cancellation or hard-kill evidence. | +| Retention observability | Nine API-only instruments under `agent_framework.durabletask` measure evaluations, budget/state bytes, staged message/entry removals, reclaimed bytes, capacity failures, write attempts and operations. All 20 local OTel units pass, including bounded attributes, exact counts, plan/staging separation and rollback. No payloads or IDs are metric dimensions. Host `set_state` returns and failures both leave commit status `unknown`. Live media tests pair staged counts with separate persisted readback and next model input, not host-confirmed commit metrics. The SDK is a dev dependency, with application-owned provider/exporter configuration. | | Explicit follow-up capabilities | Bounded completion bookkeeping, optional retry-safe external writes and provider lifecycle APIs remain follow-ups. .NET eager pruning remains gated on safe exclusion, summary, cadence and decorator support. | -Missing combined tests and instrumentation are work needed for the proposed contract, not evidence -that those capabilities require a new design or permission to defer them. Completing this ADR does -not complete their implementation or validation. The local 525-test rerun covering the existing -retention, fidelity, execution and delivery tests passed without adding new cases. -Exact Pydantic 2.11 runtime validation remains unverified because artifact downloads were blocked. +The focused unit counts are included in each 3,427-test total. The five new live tests use a +deterministic `BaseChatClient`, not Foundry. The existing 42 direct and 43 Functions tests remain +text-based and include Foundry-backed scenarios. Lint, format, both source analyzers, both test type +checks, the offline lock and both package builds passed. Mutation checks reject disabled retention, +metadata loss, missing rollback and missing telemetry. Live mutations fail on backend state checks, +and restored runs pass. Exact Pydantic 2.11 runtime validation is still blocked by artifact +downloads. No new coverage percentage, compiled C# or cross-runtime schema release acceptance is +claimed. Required validation above remains in force, including shared rollout and rollback gates. +[Schema PR #92][schema-pr] at `eff12f4` now treats `historyBinding` as an optional runtime profile, +keeps message widening scoped to v2 and retains known invocation outcomes after expiry. Its 96 +structural cases and four fixtures pass locally. Those three review threads are resolved, not a +claim of serializer interoperability or runtime activation. Exact profile definitions, legacy +transition representations and release compatibility still need implementation review. +The Python-only backend convenience and media discussion still need published evidence and reviewer +confirmation. No merge is implied by these validation results. ### Historical implementation at c4582a1