[Python] Durable thread compaction and history providers (ADR 0032) - #59
[Python] Durable thread compaction and history providers (ADR 0032)#59Ahmed Muhsin (ahmedmuhsin) wants to merge 32 commits into
Conversation
Adds DurableHistoryProvider, a core HistoryProvider whose store is the agent's durable entity state. Because it is an ordinary provider, a CompactionProvider configured the normal way runs against durable history unchanged. Compaction is reconciled by message id rather than by position, since strategies may insert messages (summaries) as well as annotate them. That required persisting message ids and making DurableAgentStateMessage serialization symmetric: extension_data was read on load but silently dropped on save, so compaction annotations were destroyed on every turn. The ADR records the core interface gaps found while doing this.
…s; add prune_history
…t protocol on both hosts
The '!python/packages/**' negation earlier in the file un-ignored everything beneath it, so the integration test .env files holding endpoints and credentials were staged by a plain 'git add'. A trailing '**/.env' rule wins over that negation; .env.example templates stay tracked.
…session id Two ways an agent that works in core could silently lose its conversation under the durable runtime, both failing without an error: - Ownership of history was decided from the chat client's STORES_BY_DEFAULT alone. Core's rule is that an explicit 'store' in the agent's options wins, so an agent using the Responses API with store=False kept a plain in-memory provider that the durable runtime never persists. - The entity built its per-operation session without an id, so core generated a fresh one each turn. External history providers (Cosmos, Redis, file) key their storage on session.session_id and were therefore reading and writing a different key on every turn.
…ADR 0032 Documents the two rules the fixes above depend on (store precedence over STORES_BY_DEFAULT, and stable session ids for external providers), and restores the entity lifetime/TTL section. TTL is the natural sibling of the retention setting this ADR introduces - the retention rationale already refers to it - and the .NET/Python parity gap it describes belongs in this repository.
…mpaction Three samples, each showing an agent configured the ordinary core way running durably with no changes: compaction on the standalone worker (13) and on Azure Functions (14), and a user-owned external history store (14, Redis). The Redis sample defines its own small provider rather than depending on agent-framework-redis, whose only release is a beta that no longer imports against current core. Integration coverage asserts against real storage: compaction annotations and message ids survive entity serialization, an external provider keeps the whole conversation under one key, and a downstream workflow agent can reference the upstream conversation. Existing continuity tests were strengthened to assert recall rather than a bare 200.
Core documents the per-provider 'state' dict handed to before_run/after_run as durable for the life of the session and persists it through AgentSession.to_dict(). The entity built a fresh session per operation, so everything providers kept there was discarded at the end of every turn: tool approval rules and queued approval requests, todo lists, background-task state, memory extraction state. Nothing failed - agents just silently started over. That is a poor fit for a runtime whose headline scenario is long-running human-in-the-loop, where an approval flow that spans turns cannot work if the pending requests are dropped between them. The entity now persists the whole serialized session instead of individual fields, which also removes the hand-rolled serviceSessionId state field and its capture/restore helpers - that id is already part of AgentSession.to_dict(). The durable history provider's own slice is excluded, since it is derived from conversationHistory and would otherwise duplicate the transcript. Restore applies the stored state onto a session built by the agent's own create_session(), preserving its session type. Known limitation, recorded in the ADR: core's state type registry is process-local and only pre-registers Message, so to_dict-based values come back as plain data rather than their original class. Core's own state is mostly plain data and its tool-approval accessor takes either form, so this is latent; the fix belongs in core.
Core deserializes session state through a type registry it seeds with exactly one entry (Message); anything else must be registered explicitly, and the registry is process-local. to_dict-based types are never auto-registered - only Pydantic models are, and only as a side effect of serializing. A durable entity routinely restores in a process that never serialized the value, so provider state came back as plain dicts instead of its own classes. Before restoring, the entity now registers the serializable types already loaded in the process. Nothing is imported from persisted data, so this cannot load code the application has not already loaded itself, and that is sufficient in practice: whoever put a value in the state bag had to import its class to construct it. The walk covers SerializationMixin subclasses and costs tens of microseconds. Pydantic values in state remain uncovered (they are keyed by class name and walking every BaseModel subclass would be broad and collision-prone). Core seeding the registry with the types it ships would make this unnecessary - register_state_type() is already public and documents cold-start restore as its motivating case.
The gaps section claimed compaction 'bypasses the provider', which overstates it and would not survive review. Only one of CompactionProvider's two hooks is coupled to session state: before_strategy acts on the loaded invocation context and already works for every provider, so external stores do get in-run context bounding. What they do not get is the framework rewriting their store. Whether that is a defect depends on who owns the store - not rewriting a user's Cosmos container is defensible, but durable entity state is framework-owned, which is what makes it a real problem here rather than a reasonable omission. It is also unresolved rather than decided: ADR-0019 names three compaction points, scopes in Redis and Cosmos, and leaves the mechanism as an explicit open question that shipped unanswered. The languages then diverged - .NET put store reduction on the provider (IChatReducer, InMemory only; Cosmos has none), Python put it in CompactionProvider reaching into session state - and neither offers it to external providers. Also corrects the knock-on claims elsewhere in the ADR that both core hooks apply 'unchanged', since L2 in fact carries workaround code, and cross-references the two gaps recorded in other sections.
These gaps are being followed up rather than fixed, so the ADR has to be the durable record. Three were under-captured: - The per-service-call cadence split was only ever discussed, never written down. Added as gap 4: history providers move to per-model-call while CompactionProvider stays per-run, so compaction annotates after the last flush. Latent (HarnessAgent only), but the symptom would be missing annotations rather than an error. - The .NET parity note said 'add extension data', which is misleading. .NET already has an ExtensionData property, but it is [JsonExtensionData] - the JSON overflow bucket, not a mapping of ChatMessage.AdditionalProperties. Annotations are lost at the conversion boundary, and MessageId does not exist at all. Anyone auditing for 'is extension data persisted?' would see the property and wrongly close the item. - Recorded that the store-precedence rule is re-derived here because core does not expose it, that drift would present as silent conversation loss, and that the only real net is the compaction sample rather than the unit tests.
The ADR had grown into two documents in one hat: a forward-looking design decision in the present tense, followed by a retrospective implementation log, with no signal where one ended and the other began. Adds a short orientation note, marks the status accepted (Python implemented, .NET pending), and fixes the Context section's claim that the durable layer benefits from neither hook 'today' - no longer true. Also notes once that .NET's ChatHistoryProvider and Python's HistoryProvider are the same concept, since the decision sections use one name and the implementation sections the other. Deduplication: service-managed scope was stated four times and storage-capacity-is-separate five; each now has one home plus pointers. The per-option pros/cons lists restated Decision Outcome almost verbatim and are now one entry per option. Three Cross-Cutting bullets that repeated the drivers and the L1/L2 table are gone, as is the 'Why Option 6 over Option 2' paragraph now covered by the options summary. Validation was written as intent; it now separates what is actually covered in Python from what is still outstanding, so the .NET gap is visible rather than implied. 549 -> 504 lines, 5267 -> 4877 words, with no information removed.
Punctuation pass over the material added on this branch: the ADR, the three new sample READMEs, and the docstrings, comments and log messages in the new provider, entity and sample code. Em dashes become commas, parentheses or sentence breaks, and semicolons joining independent clauses become separate sentences. Colons are kept only where they label something (Args, Returns, 'Chosen option', 'Workaround') rather than standing in for a conjunction. Pre-existing text is left alone, so the em dashes still in _models.py, _workflows/context.py, _workflows/orchestrator.py, tests/test_app.py and samples/README.md are untouched - none of those lines are from this branch, and rewriting them would add unrelated churn. Also fixes an indentation slip introduced while editing a comment in _history_provider.py.
The decision has not been accepted yet, so status goes back to proposed. Deciders and consulted are left blank rather than naming people who have not signed off. Also reverts one word in the orientation note: it said the design was 'realized' in Python, which implied a settled decision. It is a prototype, which is how the rest of the ADR already describes it.
…hat could not fail The session state bag was the one change on this branch verified only in-process. Its unit tests keep the session dict in memory, so they cannot show that the blob survives the entity's JSON encoding, that it carries the entity's own session id rather than a per-operation one, or that the durable history slice is really left out. A new test in test_13 reads the entity back from the scheduler and asserts all three against the real payload. Two pre-existing tests were passing regardless of behavior: - test_06 test_conditional_branching scheduled one spam email and asserted only that the orchestration COMPLETED, never which branch ran, so it would pass if the condition sent every email down the same path. It now asserts the branch-specific output and covers the legitimate branch too, which a stale comment implied was once intended. - test_07 test_hitl_orchestration_timeout wrapped the wait in 'except (RuntimeError, TimeoutError): pass'. Since the shared helper raises on FAILED, its assert was unreachable and the test passed on every outcome including a hung orchestration. It now waits on the client directly and asserts the run failed with an approval timeout rather than for some other reason.
There was a problem hiding this comment.
Pull request overview
Implements ADR 0032 for Python by making durable agents/workflows reuse core conversation-history + compaction plumbing (via a durable-backed HistoryProvider), persisting per-session provider state across turns, and forwarding upstream workflow context to downstream agent nodes for parity with in-process execution.
Changes:
- Add
DurableHistoryProvider+ automatic history-provider substitution so core compaction runs unchanged against durable entity-backed history (with opt-inprune_historyretention). - Persist serialized
AgentSession(provider state + service conversation id) in durable entity state across turns, excluding the durable history slice to avoid transcript duplication. - Add workflow context projection (
context_mode/context_filter) intoRunRequest.context_messages, plus new/updated unit + integration tests and samples.
Reviewed changes
Copilot reviewed 50 out of 51 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| schemas/durable-agent-entity-state.json | Extends durable agent state schema to include persisted serialized session payload. |
| python/samples/README.md | Documents new conversation-history/compaction samples. |
| python/samples/azure_functions/14_conversation_compaction/requirements.txt | Dependencies for the new Azure Functions compaction sample. |
| python/samples/azure_functions/14_conversation_compaction/README.md | Explains durable-backed history + compaction behavior for the Functions sample. |
| python/samples/azure_functions/14_conversation_compaction/local.settings.json.template | Local settings template for the Functions compaction sample. |
| python/samples/azure_functions/14_conversation_compaction/host.json | Durable Functions host configuration for the sample. |
| python/samples/azure_functions/14_conversation_compaction/function_app.py | Functions sample wiring demonstrating durable-backed history + compaction. |
| python/samples/azure_functions/14_conversation_compaction/demo.http | Ready-made HTTP sequence for exercising compaction behavior. |
| python/samples/14_external_history_redis/worker.py | New sample worker hosting an agent whose history is stored in Redis. |
| python/samples/14_external_history_redis/sample.py | Combined worker+client runner for the external Redis history sample. |
| python/samples/14_external_history_redis/requirements.txt | Dependencies for the external Redis history sample. |
| python/samples/14_external_history_redis/redis_history_provider.py | Minimal sample HistoryProvider implementation backed by Redis. |
| python/samples/14_external_history_redis/README.md | Explains external-store history behavior under the durable runtime. |
| python/samples/14_external_history_redis/client.py | Sample client that demonstrates recall via Redis-backed history. |
| python/samples/14_external_history_redis/.env.example | Environment template for the Redis history sample. |
| python/samples/13_conversation_compaction/worker.py | New durabletask sample worker for durable-backed history + compaction. |
| python/samples/13_conversation_compaction/sample.py | Combined worker+client runner for the compaction sample. |
| python/samples/13_conversation_compaction/requirements.txt | Dependencies for the durabletask compaction sample. |
| python/samples/13_conversation_compaction/README.md | Explains durable-backed history + compaction semantics for durabletask. |
| python/samples/13_conversation_compaction/client.py | Sample client validating bounded context + recent recall. |
| python/samples/13_conversation_compaction/.env.example | Environment template for the durabletask compaction sample. |
| python/packages/durabletask/tests/test_workflow_context_parity.py | Unit tests for projecting upstream workflow conversation into downstream runs. |
| python/packages/durabletask/tests/test_durable_history_provider.py | Unit tests for durable-backed history provider + compaction persistence/pruning. |
| python/packages/durabletask/tests/test_durable_history_autoswap.py | Unit tests for auto-swapping history providers without mutating the user agent. |
| python/packages/durabletask/tests/integration_tests/test_14_dt_external_history_redis.py | Integration tests for external-store history continuity + stable session id. |
| python/packages/durabletask/tests/integration_tests/test_13_dt_conversation_compaction.py | Integration tests for durable compaction behavior + session persistence shape. |
| python/packages/durabletask/tests/integration_tests/test_08_dt_workflow.py | Adds assertion that downstream workflow agents receive upstream conversation. |
| python/packages/durabletask/tests/integration_tests/test_07_dt_single_agent_orchestration_hitl.py | Fixes HITL timeout test to assert failure reason instead of swallowing errors. |
| python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py | Strengthens conditional-branch assertions to validate correct branch output. |
| python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py | Makes conversation-continuity test actually depend on persisted history recall. |
| python/packages/durabletask/agent_framework_durabletask/_workflows/orchestrator.py | Adds L3 context projection into agent tasks via context_messages. |
| python/packages/durabletask/agent_framework_durabletask/_workflows/dt_context.py | Routes agent-task creation through shared build_agent_task helper. |
| python/packages/durabletask/agent_framework_durabletask/_workflows/context.py | Extends orchestration-context protocol to accept optional context_messages. |
| python/packages/durabletask/agent_framework_durabletask/_worker.py | Adds prune_history defaults/overrides when registering agents as entities. |
| python/packages/durabletask/agent_framework_durabletask/_shim.py | Introduces build_agent_task + forwards optional context_messages to executors. |
| python/packages/durabletask/agent_framework_durabletask/_models.py | Adds RunRequest.context_messages wire field with (de)serialization. |
| python/packages/durabletask/agent_framework_durabletask/_history_provider.py | New durable-backed HistoryProvider + auto-swap logic and compaction reconciliation. |
| python/packages/durabletask/agent_framework_durabletask/_executors.py | Extends run-request construction to carry orchestration id + optional context messages. |
| python/packages/durabletask/agent_framework_durabletask/_entities.py | Switches entity execution to use core context pipeline + persists session + dedupes upstream context. |
| python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py | Adds persisted session + message id + extension metadata to durable state entries/messages. |
| python/packages/durabletask/agent_framework_durabletask/_constants.py | Adds new durable state field constants for message id + session. |
| python/packages/durabletask/agent_framework_durabletask/init.py | Exports newly added durable history + task-building utilities. |
| python/packages/azurefunctions/tests/test_app.py | Updates tests for entity creation signature to include prune_history. |
| python/packages/azurefunctions/tests/integration_tests/test_14_conversation_compaction.py | New integration coverage for Functions-hosted durable compaction sample. |
| python/packages/azurefunctions/tests/integration_tests/test_01_single_agent.py | Strengthens Functions continuity test to require history-based recall. |
| python/packages/azurefunctions/agent_framework_azurefunctions/_workflow_af_context.py | Uses shared build_agent_task and updates protocol signature for context messages. |
| python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py | Aligns orchestration id propagation via executor hook instead of overriding get_run_request. |
| python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py | Adds prune_history option when creating agent entities. |
| python/packages/azurefunctions/agent_framework_azurefunctions/_app.py | Adds app-level + per-agent prune_history plumbing through entity setup. |
| docs/decisions/0032-durable-thread-compaction.md | Adds ADR 0032 describing the approach and Python prototype notes. |
| .gitignore | Ignores .env files repo-wide while keeping .env.example tracked. |
CI type-checks tests with mypy in addition to ruff and pyright. I ran the other two locally but not mypy, so all four Python jobs failed on the first push. Most errors were stub clients and stub agents passed where the full client or agent protocol is expected. Rather than scattering per-call-site ignores, each affected test file now builds its agent through a small helper that relaxes the type once. That also removed some duplicated construction. Two were real rather than cosmetic. test_durable_history_provider instantiated the abstract HistoryProvider directly, which now uses a concrete stub, and test_durabletask_workflow_initial_input had a context stub whose prepare_agent_task predated the context_messages parameter this branch adds to the protocol. The remaining local mypy error is in integration_tests/conftest.py and comes from redis typing in my environment. CI does not report it, and the file is untouched here.
redis-py types lrange differently depending on version, so annotating the result as list[str] passed locally and failed on CI with list[bytes | str]. The helper now takes the result loosely and coerces each entry, which holds either way.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 52 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
python/packages/durabletask/agent_framework_durabletask/_history_provider.py:270
- The
stored_by_idmap stores(entry, index)positions, but_insert_new_message()mutatesentry.messagesduring the same flush. After an insertion, indices for later messages in that entry become stale, soentry.messages[index]can point at the wrong message (potentially overwriting annotations or pruning the wrong item). Storing direct message references (or re-resolving bymessage_idafter insertions) would avoid index drift.
entry, index = position
stored = entry.messages[index]
stored.extension_data = annotations
last_known = position
if self.prune_excluded and annotations and annotations.get(EXCLUDED_KEY):
python/samples/azure_functions/14_conversation_compaction/function_app.py:81
- This trailing triple-quoted string is an unused string literal (not a docstring), so it has no effect and adds dead code. Convert it to comments or fold it into the module docstring at the top of the file.
…peat Both issues come from review on PR 59 and both were real. Each corrupts durable state quietly rather than raising. flush() looked messages up by an index recorded before the run, then inserted compaction-generated summaries into the same entry. The insertion pushed every later message along by one, so the recorded index then pointed at the wrong message and its annotations were written there. Pruning had the same flaw and could delete the wrong message. Positions are now shifted alongside the insertion, and pruning removes by identity rather than index. This stayed hidden because entries normally hold a single message. A workflow node receives the upstream conversation as several messages in one request entry, which is where it bites. The new regression test builds that shape and fails without the fix. _drop_already_stored() kept the newest message when the whole upstream context was already recorded, so the agent still had an input, but it kept the id too. Two stored messages under one id collide in the position map, so only the later one was ever annotated and the earlier copy could never be excluded. The kept copy now drops its id and is assigned a fresh one on load.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 52 changed files in this pull request and generated no new comments.
Suppressed comments (1)
python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py:882
to_chat_message()passesself.extension_datadirectly asadditional_properties. Because this is a mutable dict that is also retained on the durable state message, downstream code (e.g., compaction annotatingMessage.additional_properties) can accidentally mutate durable state in-place before the provider’s explicit flush/persist step runs, leading to surprising side effects (especially on error paths). Make a defensive copy when constructing theMessage.
if self.extension_data is not None:
kwargs["additional_properties"] = self.extension_data
…identity Third issue from review on PR 59, and also real, though not for the reason given. Messages stored without an id were given one built from id(entry). Within a single load and flush cycle that is consistent, and the id is written back into durable state, so a cold start before the first flush just regenerates a fresh consistent set rather than corrupting anything. The actual hazard is address reuse. A later run can allocate an entry at an address a previous run already used, producing an id that run persisted. Two stored messages then share a key in the position map, which is the same corruption the duplicate id fix addressed. The id now comes from the entry type, its correlation id or created_at, and the message index, all of which are persisted. The entry type is needed because a request and its response share a correlation id. The new test reloads the same state twice and fails when the id is taken from object identity.
Chris Gillum (cgillum)
left a comment
There was a problem hiding this comment.
Added some initial comments on just the parts of the ADR that I've read so far. I haven't been able to get through the full PR yet.
The three samples added on this branch read AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_MODEL. CI only sets FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL, so every worker subprocess died with KeyError: 'AZURE_OPENAI_MODEL' and took both new integration test classes down with it. Local runs passed because my integration .env happens to carry both sets of variables. Every other sample in the repo uses the Foundry pair, so this was a convention break my environment hid. The samples now use FoundryChatClient, with the env templates, requirements, Functions settings template, and READMEs updated to match. default_options store=False still carries the point of the compaction samples, since Foundry stores conversations on the service by default too. Verified against the real service: test_13 is 5 for 5 and test_14 is 4 for 4, so compaction is genuinely operating on client-side history.
…ion handling All four come from review on PR 59, all four were real, and all four are in code this branch added. Each was reproduced with a test that fails without the fix. The duplicate check never fired for messages the workflow itself built. build_agent_executor_response left message_id unset and core does not fill one in, so every forwarded message looked new and a node in a cycle re-recorded the whole conversation on each visit, growing state quadratically. The check was written to prevent exactly that. It now stamps an id derived from the message's position, which is fixed once the message joins the conversation and is rebuilt identically on replay. The existing tests missed this because they assigned ids by hand, which is the one case that already worked. Every agent node in a workflow run received the same core session id. The id was the entity key alone, and workflow entities share the orchestration instance id as their key while differing by entity name, so an external history provider keyed on it filed every node's conversation under one entry. That broke the external provider scenario this branch is meant to support. The core session id is now qualified with the entity name in the existing @name@key form. The plain session_id still flows to callbacks and logs, so streaming is untouched, and the new entity-name hook defaults to empty so older state providers keep working. Session state was assigned to durable state without checking it could be stored. Core neither raises nor warns on a value it cannot serialize, it passes the live object through, and the entity state provider serializes eagerly. The save therefore failed, and the error handler saved again with the same payload, so the second failure escaped and buried whatever the agent had actually returned. The payload is now validated first and the last good session is kept otherwise. The in-memory test provider now serializes on write like the real one, so the test reproduces that whole chain rather than only its first step. The durable history slice was removed after serializing rather than before, so the full transcript and its position index were serialized and then discarded on every turn. It is now removed first and restored afterwards. Two integration tests pinned the old bare-key shape. The Redis one now discovers the key instead of reconstructing it and asserts only one matches, which also proves the conversation is not scattered. Worth recording that the runtime lowercases entity names, so the persisted id reads @dafx-historian@<key>.
…wrong Review on PR 59 was right on several counts and the document said things that were not true. Corrections. Azure Storage has no hard state limit, it offloads anything over 45 KB to blob and pays for size in CPU and memory instead. The 1 MB cap belongs to the scheduler. ChatMessage.MessageId does exist in .NET, so it needs mapping rather than inventing, and .NET keeps exclusion state on CompactionMessageGroup rather than in AdditionalProperties, which carries the summary marker instead. The claim that the built-in store enforces a limit and surfaces a clear error as it is approached was aspirational, nothing measures state size today, and it is withdrawn. Framing. Calling entity state a system of record that auto-derived reduction would silently destroy overstated it. It is a state bag, deleting from it is legitimate, and the driver now says deletion should be a last resort, proportionate, and observable. The service-managed driver now says model provider, because the durable entity is service-managed too under the other reading. L3 is recorded as a weaker seam rather than parity, since core compaction is agent-level and a workflow node inherits L1 unchanged while the inter-executor conversation only has a plain callable. New material. Option 7 adds the scheduler's large payload extension as the first capacity answer, non-lossy and needing no code from this layer. A fourth core gap records why L2 cannot work in .NET yet, because CompactionProvider persists full ChatMessage copies into the session state bag, so a durable provider either stores the transcript twice, loses exclusions and summaries, or forces an index rebuild. Retention replaces prune_history with three modes and auto as the default. It sits at the entity rather than the history provider, so it also covers external providers, service-managed agents and agents with no context pipeline, which previously had no mitigation at all. Under auto it clears context exclusions on a detached view before asking core for a verdict, because the budget is computed over included messages and a user's own window would otherwise make an over-budget conversation look empty. It passes no strategies, since early stop would satisfy the budget immediately and delete everything the user had excluded. Deletion reuses the existing prune path. Also records that TTL is a sliding idle timer, so an active conversation never expires and TTL does not substitute for retention.
…kend limit Until now nothing measured how large entity state was getting. An agent in a long conversation grew until the scheduler refused the write, with no warning and no relief, and the only mitigation was a flag that did nothing unless compaction was already configured. Retention replaces prune_history with three modes. keep_all never deletes and lets the entity fail, which is the honest choice when the record matters more than availability. auto, the default, deletes only under storage pressure and only down to the low watermark. follow_compaction also deletes what compaction excluded, which is the old prune_history=True. It lives on the entity rather than the history provider because the entity records the conversation in every configuration. External providers, service-managed agents and agents with no context pipeline all accumulate state, and none of them could be protected by a provider-level hook. The eviction itself is almost entirely core's. TokenBudgetComposedStrategy with no strategies of its own goes straight to a deterministic oldest-group eviction that preserves system messages and keeps tool-call groups whole, and deletion reuses the prune path that already existed. What is new is the size check and converting a byte budget into a token budget, which calibrates from the measured ratio of content to serialized bytes rather than assuming an overhead constant. Two details worth recording. The budget is computed over a detached copy with context exclusions cleared, because the strategy budgets over included messages and a user's own sliding window would otherwise make an over-budget conversation look empty and evict nothing. And the user's strategy is deliberately not passed in, since early stop would satisfy the budget immediately and everything they had excluded for context reasons would be deleted. Writing the tests surfaced a real defect. Given a single turn larger than the budget, core's fallback drops everything, including the exchange that just completed, which would discard the result the caller is polling for. The newest exchange is now held back from eviction, grouped by correlation id so a request and its response are protected together.
…e what we persist Two changes that only became necessary once retention could delete messages. Duplicate detection for workflow context compared incoming ids against the ids currently in history. Retention deletes oldest-first, which removes exactly those ids, and the orchestrator re-sends them because its own conversation is never evicted. The entity would then re-record precisely what had just been deleted, and since the re-ingested volume is proportional to what was evicted, that oscillates rather than settling. Detection is now by position. The entity keeps the highest chained-conversation position it has taken from each executor, which is a handful of integers, is unaffected by deletion, and is per executor rather than global because a fan-out hands two branches the same position. Once a message is evicted the node stops seeing it, which is intended: re-ingesting evicted content defeats the eviction. The id format and its parser now live together in naming.py instead of being an inline f-string. The shared schema also under-declared what this runtime persists. messageId and extensionData are both load-bearing for compaction and neither was declared, so a .NET implementer reading the contract had no way to know they must round-trip. Nothing failed validation, because the schema permits extra properties, which is exactly why it went unnoticed. Contrary to the review comment, a strict validator would not have rejected these payloads. The real defect was silent under-documentation. Session is now described as opaque and runtime-discriminated rather than pinning Python's shape, since .NET serializes conversationId plus stateBag and Python serializes session_id, service_session_id and state. Declaring either would invalidate the other. Schema version bumped to 1.2.0. Tests validate real persisted state rather than a synthetic dict, both in unit form and against the scheduler, so the code and the contract cannot drift apart quietly again. Worth recording that message ids are assigned when history is first loaded rather than when it is written, so a single-turn conversation legitimately has none.
The existing retention tests call enforce_budget directly, which proves the eviction algorithm but not that anything calls it. These drive a real Agent through AgentEntity.run for twenty turns against a small budget, so the assertion covers the wiring: session load, durable history, agent run, save, enforcement, persist, reload. The keep_all case is the control. It asserts state grows past the limit on the same run, so the bounded assertion cannot pass because the conversation was small. The trimmed assertion exists for the same reason, since a bound holds trivially if nothing accumulates. The fake client answers based on the question rather than a call counter, because the entity retries through a non-streaming fallback and a counter would drift. It returns a ResponseStream so the streaming path the entity actually prefers is the one under test. Also covers the deprecation shim end to end, which was only tested at the resolve_retention level, and the sample README described prune_history, which is no longer how this is configured.
…hon path It cannot, in either version, and the ADR was guessing. 1.x is what this package pins, and it does not depend on the durabletask SDK at all. The host extension owns persistence, so there is no Python-side seam to configure and no reference to payloads anywhere in the package. The earlier wording called this dotnet-only support, which described the symptom but not the reason. The 2.x preview does depend on durabletask, and DurableFunctionsWorker subclasses TaskHubGrpcWorker, whose constructor takes payload_store. But its own __init__ takes no parameters and hardcodes the super call, and the client takes only a connection string. Neither forwards kwargs, so the capability is inherited and unreachable. Its comment even lists the payload store among the base state it relies on. This matters for the decision rather than being trivia. Blob offload stays the first capacity answer on the durabletask path, where the caller builds the worker and client and can pass a payload store with no code from us. On Functions it is not an answer at present, which is why retention has to exist rather than being deferred to a ceiling raise. Recorded as gap 6 with the upstream ask. The outstanding retention note was also stale, since all three modes are now built and tested.
Separate agent compaction, workflow context projection, and durable retention so each mechanism has one clear responsibility. Correct the claims about external history providers, prospective .NET retention, service-managed context, and the workflow context seam. Keep the schema, .NET compaction-state, blob offload, watermark, and position-dedup findings that answer review feedback. Remove the duplicate option recap, implementation detours, and the out-of-scope TTL plan. The ADR drops from 7,220 words to 3,914 while retaining the decision, tradeoffs, validation, and unresolved gaps.
prune_history was introduced and replaced within this draft branch, so deprecating it would preserve an API that no release ever exposed. Remove the flag from worker and Functions registration, delete the compatibility resolver, and use retention modes directly. follow_compaction remains the explicit policy for deleting compaction exclusions. Clarify that follow_compaction still runs pressure eviction when eager pruning is insufficient. Add an end-to-end control with no compaction strategy, proving the shared fallback keeps state bounded.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 57 out of 58 changed files in this pull request and generated no new comments.
Suppressed comments (1)
python/packages/azurefunctions/agent_framework_azurefunctions/_app.py:1156
AgentFunctionApp.__init__acceptsmax_state_bytesand stores it asself._max_state_bytes, but it’s never passed through when creating the durable entity. As a result, callers cannot actually change the retention/enforcement budget for Azure Functions-hosted agents (the entity always uses the default fromcreate_agent_entity).
entity_handler = create_agent_entity(agent, callback, retention=retention)
Summary
Makes core's compaction and history providers work on the durable runtime with no changes to a user's agent. Implements ADR 0032 for Python. .NET is not implemented yet.
An agent that already works in core can be registered with the worker or
AgentFunctionAppand gets durable conversation history automatically. An attachedCompactionProviderkeeps working, and a provider the user chose deliberately (Cosmos, Redis, file) is left alone.What changed
DurableHistoryProviderbacks conversation history with the agent's durable entity state. Because it is an ordinary coreHistoryProvider, compaction configured the normal way runs against durable history unchanged.Zero-configuration registration. The entity substitutes the history provider at construction, which covers both hosts. Nothing configured gets a durable provider, an
InMemoryHistoryProvideris replaced while preservingsource_idandskip_excluded, and external or service-managed history is left alone. The caller's agent is never mutated.Workflow context parity (L3). The orchestrator projects
full_conversationaccording to the executor'scontext_modeandcontext_filter, then delivers it to the agent entity. Previously a downstream agent received only the last message's text.The session is persisted. Anything context providers keep in session state was discarded at the end of every turn, including tool approval rules and queued approval requests. The entity now persists the serialized session, which also replaces a hand-rolled
serviceSessionIdfield.prune_historyis an opt-in registration flag that deletes compacted-out messages from durable state. It is lossy, so it is off by default.Bugs found while building this
DurableAgentStateMessage.to_dict()droppedextension_datawhilefrom_dict()read it, so compaction annotations were destroyed on every save.STORES_BY_DEFAULTalone. Core's rule is that an explicitstorewins, so an agent using the Responses API withstore=Falsesilently lost its conversation.Message.Each failed silently rather than raising, which is why they are called out here.
Testing
Unit tests pass for both packages. Durable Task integration is 40/40 and Azure Functions integration is 42/42, both against real infrastructure.
Three new samples carry integration coverage: compaction on the standalone worker, compaction on Azure Functions, and an external Redis history store.
Two pre-existing integration tests were passing regardless of behavior and are fixed here.
test_06never asserted which branch ran, andtest_07had an unreachable assert behind a bareexcept: pass.Follow-ups
Core gaps are recorded in the ADR rather than fixed here. The main one is that the store-rewrite hook is bound to session state rather than to the provider, which ADR-0019 raised as an open question and left unanswered.
.NET parity needs the same message schema fixes. Its existing
[JsonExtensionData]property looks like it covers this but does not, since annotations are lost when converting to and fromChatMessagerather than at the JSON boundary.