Multi-turn sessions and message history¶
By default each agent run is a cold, single-turn conversation. To carry a
conversation across runs – a chat or iterative agent where “and the third one?”
must resolve against an earlier answer – pass message_history.
When message_history is set, the operator seeds the run with those prior
turns and, after the run, pushes the full updated transcript
(result.all_messages()) to XCom under the key message_history. The next
run reads it back to resume the conversation. None (the default) keeps the
single-turn behavior unchanged.
The operator does not decide where a session is stored – that keying is
deployment-specific. The pattern is three tasks: load the prior transcript for
the session, run the agent, store the updated transcript. The example keys a
JSON file in object storage by session_id (use s3:// / gs:// in a
deployment); the first run starts from an empty "[]".
@dag(tags=["example"], params={"session_id": "demo-session"})
def example_agent_session():
"""Resume a conversation across runs via ``message_history``.
The agent step seeds itself with the prior transcript and re-emits the
updated transcript to XCom (key ``message_history``). Loading and storing
that transcript under a session key is the DAG's job -- here, a JSON file in
object storage keyed by ``session_id``. Swap the path for ``s3://`` /
``gs://`` in a deployment.
"""
sessions_root = ObjectStoragePath("file:///tmp/airflow_agent_sessions")
@task
def load_history(session_id: str) -> str:
path = sessions_root / f"{session_id}.json"
# First turn: no file yet -> start a fresh session (empty transcript).
return path.read_text() if path.exists() else "[]"
@task.agent(
llm_conn_id="pydanticai_default",
system_prompt="You are a helpful assistant. Use the earlier turns for context.",
# The XComArg both wires the dependency and resolves to the JSON transcript.
message_history=load_history("{{ params.session_id }}"),
)
def ask(question: str) -> str:
return question
@task
def save_history(session_id: str, transcript: str) -> None:
# Local/fsspec object storage does not auto-create parent dirs on write.
sessions_root.mkdir(parents=True, exist_ok=True)
(sessions_root / f"{session_id}.json").write_text(transcript)
answer = ask("And what did I ask you a moment ago?")
saved = save_history(
"{{ params.session_id }}",
# The agent step pushes the post-run transcript under this XCom key.
"{{ ti.xcom_pull(task_ids='ask', key='message_history') }}",
)
# save runs after the agent so the pulled transcript is the fresh one.
answer >> saved
message_history accepts a list of pydantic-ai ModelMessage objects or
their JSON form (str / bytes), so the value emitted to XCom feeds
straight back in on the next run. When pulling it via a template, pass
default='[]' (as above) so the first run – which has no XCom yet – starts a
fresh session instead of trying to parse the string "None".
Note
message_history cannot be combined with enable_hitl_review – the
operator raises at construction. The post-review (human-approved) transcript
is not recoverable today, so emitting the pre-review transcript would
silently drop the reviewed turns.
Message format, and limiting session size¶
The transcript is stored as pydantic-ai’s JSON message format. It is
cumulative: each turn appends to it, so it grows for the life of the
session. The operator does not trim it itself, but capabilities= is passed
to the pydantic-ai Agent, so the ProcessHistory
capability from pydantic-ai’s documentation works on AgentOperator as is:
from pydantic_ai.capabilities import ProcessHistory
from pydantic_ai.messages import ModelMessage, ModelRequest, RetryPromptPart, ToolReturnPart
def keep_recent(messages: list[ModelMessage]) -> list[ModelMessage]:
tail = messages[-10:]
# Start at a user turn. A tail that begins at a request holding only tool
# returns or retries (mid-run) loses the user's question.
for i, message in enumerate(tail):
if isinstance(message, ModelRequest) and not any(
isinstance(part, (ToolReturnPart, RetryPromptPart)) for part in message.parts
):
return tail[i:]
return messages
AgentOperator(
task_id="chat",
...,
capabilities=[ProcessHistory(keep_recent)],
)
pydantic-ai writes the processed list back into the run state, and
result.all_messages() returns that list. With a history processor set, the
transcript pushed to XCom is therefore the trimmed one, and turns the
processor drops are no longer part of the stored session. Alternatively, read the
message_history XCom in a downstream task, trim it, store it in your own
session store, and load it from there on the next run; or configure an
object-storage XCom backend for long sessions.
The operator has no built-in cross-session memory. To let an agent use facts from earlier sessions, store them yourself, load them in an upstream task (the same load, run, store pattern as above), and put them in the prompt or the instructions. To let the agent keep its own notes across Dag runs instead, see Agent memory.