Agent Message History#
dify-agent constructs LLM message history in two stages β assembly (building the message_history list passed to each agent.run() call) and mapping (converting that list to provider-compatible PromptMessage objects). A third concern β thread reconstruction from the persistent Message table in the API service β feeds history into classic (non-dify-agent) app paths.
1. History Assembly#
System prompts are passed as run-level pydantic-ai instructions, not injected into message_history. The runner in runner.py pulls stored history from PydanticAIHistoryLayer (if present) and passes it directly to agent.run():
message_history = history_layer.message_history if history_layer is not None else None
Current system prompts remain ephemeral β they belong to each run and are never persisted. When context_window_tokens is available, Pydantic AI Harness may compact history (clearing older tool results, then incrementally summarizing older messages) before model requests. Once pydantic-ai binds and builds messages in the run capture using its capture_run_messages() context manager that wraps the agent.run() call, replace_run_history persists the complete captured history for every terminal outcome (success, failure, cancellation, timeout), with ModelRequest.instructions fields set to None before storage. Interrupted partial messages retain state="interrupted" markers so pydantic-ai can repair and continue from the checkpoint in later runs. When a run is cancelled after tool calls are returned but before any tool result exists, the trailing model response is marked state="interrupted" to allow Pydantic AI's repair mechanism to handle those dangling tool calls, enabling the conversation to continue with a new user prompt instead of blocking with "Cannot provide a new user prompt when the message history contains unprocessed tool calls".
2. Persistence for All Terminal Outcomes#
Multi-turn conversations are stored in a PydanticAIHistoryLayer (type ID pydantic_ai.history). Key characteristics:
- State-only: contributes no prompts, tools, or live resources .
- Serializable:
runtime_state.messagesis a Pydantic model stored inside the Agenton session snapshot, enabling cross-request persistence. - Immutable-style writes:
append_messagesandreplace_messagesalways assign a new list to preserve Pydantic assignment validation . - Single reserved slot: only one history layer is permitted per composition, and it must use the reserved name
"history".
Once pydantic-ai binds and builds messages in the run capture, replace_run_history persists the captured history for every terminal outcome β success, failure, cancellation, and timeout. ModelRequest messages have their instructions field set to None before persistence, so current system prompts are not stored. If a failure or cancellation occurs before the capture contains any messages, the previously restored history is preserved. Failed and cancelled runs return terminal snapshots that checkpoint the current history without changing the run's terminal status to success.
For runs cancelled after tool calls are returned but before any tool result exists, replace_run_history marks the trailing model response with state="interrupted". This signals Pydantic AI to repair the dangling tool calls on the next run by synthesizing interrupted tool returns, allowing the conversation to continue with a new user prompt rather than blocking. Runs that finish successfully with deferred tool requests (such as ask_human scenarios) preserve their open tool calls as-is so the next run can answer them.
3. System Message Ordering: Qwen / vLLM Compatibility#
When the assembled history is converted to provider PromptMessage objects, _map_messages_to_prompt_messages in dify-agent/src/dify_agent/adapters/llm/model.py handles the mapping.
The problem: some providers (notably Qwen via vLLM) require the system message to be the first element in the messages array, and reject requests that place system content elsewhere .
Pre-fix behavior (pre-PR #39136): instruction messages extracted from model_request_parameters were inserted at the first non-SystemPromptMessage index β logically correct but producing multiple disjoint system messages that could appear out of order.
Post-fix behavior (PR #39136): after all messages are mapped, the function:
- Separates all
SystemPromptMessageobjects from non-system messages. - Merges their content with
"\n\n"separators (handles bothstrandlist-typed content). - Returns a single merged
SystemPromptMessageas element 0, followed by all non-system messages in original order. - Returns only non-system messages when no system messages exist.
4. Thread Reconstruction: extract_thread_messages#
For classic Dify app paths (not dify-agent), conversation history passed to the LLM is reconstructed from the Message table by extract_thread_messages in api/core/prompt/utils/extract_thread_messages.py. The function walks the parent_message_id chain (messages arrive in created_at DESC order) to produce a linear thread from a potentially branching tree.
Known bug (open as of 2026-07-15): The unconditional if not message.parent_message_id guard fires on any regeneration-root message encountered during the walk, not just the one currently being tracked. If an unrelated regeneration root sits between the current message and its real parent, the walk grabs it and stops early β silently truncating the thread . This affects:
core/memory/token_buffer_memory.py(conversation history sent to LLM)core/agent/base_agent_runner.py(agent conversation history)core/prompt/utils/get_thread_messages_length.pyβdialogue_count
The fix is to scope the root-detection check inside the branch that already matched via next_message, rather than checking unconditionally.
Key Files#
| File | Purpose |
|---|---|
dify-agent/src/dify_agent/runtime/history.py | replace_run_history, get_history_layer, validate_history_layer_composition |
dify-agent/src/dify_agent/runtime/runner.py | Passes instructions; wraps agent.run() with capture_run_messages() and calls replace_run_history for all terminal outcomes |
dify-agent/src/dify_agent/runtime/compaction.py | build_compaction_capability β tiered compaction when context_window_tokens is available |
dify-agent/src/agenton_collections/layers/pydantic_ai/history.py | PydanticAIHistoryLayer β serializable multi-turn history storage |
dify-agent/src/dify_agent/adapters/llm/model.py | _map_messages_to_prompt_messages β system message merging + ordering |
api/core/prompt/utils/extract_thread_messages.py | Thread reconstruction from Message table for classic app paths |