feat: add conversation_id for cross-run correlation#5251
Conversation
Adds a stable identifier shared across all agent runs that build on the same `message_history`, emitted as the `gen_ai.conversation.id` OpenTelemetry span attribute on the `agent_run` span. Resolves from explicit `conversation_id=` kwarg (with `'new'` sentinel to fork), prior `conversation_id` on `message_history`, or a fresh UUID7. Surfaces on `RunContext` for capability hooks and on every produced `ModelRequest` / `ModelResponse` so it round-trips through serialization. `AGUIAdapter` auto-populates from `RunAgentInput.threadId`; `VercelAIAdapter` auto-populates from the top-level Vercel AI chat `id`. Closes #3925.
| output validators since output validators would expect an argument that matches the agent's output type. | ||
| message_history: History of the conversation so far. | ||
| deferred_tool_results: Optional results for deferred tool calls in the message history. | ||
| conversation_id: ID of the conversation this run belongs to. Pass `'new'` to start a fresh conversation, ignoring any `conversation_id` already on `message_history`. If omitted, falls back to the most recent `conversation_id` on `message_history` or a freshly generated UUID7. |
There was a problem hiding this comment.
The conversation_id docstring entry was added here, but conversation_id is not in the function signature of handle_ag_ui_request. Same issue with run_ag_ui below (line 164). Either add the parameter to both signatures and forward it through, or remove these docstring entries.
| conversation_id: ID of the conversation this run belongs to. Pass `'new'` to start a fresh conversation, ignoring any `conversation_id` already on `message_history`. If omitted, falls back to the most recent `conversation_id` on `message_history` or a freshly generated UUID7. | ||
| model: Optional model to use for this run, required if `model` was not set when creating the agent. |
There was a problem hiding this comment.
🔴 handle_ag_ui_request documents conversation_id but lacks the parameter
The handle_ag_ui_request convenience function documents conversation_id in its docstring (pydantic_ai_slim/pydantic_ai/ag_ui.py:87) but the parameter is missing from the function signature (lines 53-72) and is not passed through to AGUIAdapter.dispatch_request() (lines 106-125). Users who follow the docstring and try to pass conversation_id='my-id' will get an unexpected keyword argument error.
Prompt for agents
The handle_ag_ui_request function in pydantic_ai_slim/pydantic_ai/ag_ui.py documents conversation_id in its docstring (line 87) but the parameter is missing from the function signature (lines 53-72). It also needs to be forwarded to AGUIAdapter.dispatch_request() in the call at lines 106-125. The fix requires adding conversation_id: str | None = None to the function signature (after deferred_tool_results), and adding conversation_id=conversation_id to the dispatch_request() call. The same issue exists for the run_ag_ui function (lines 128-206) which also documents conversation_id in its docstring (line 164) but is missing it from its signature and does not pass it to adapter.run_stream().
Was this helpful? React with 👍 or 👎 to provide feedback.
| conversation_id: ID of the conversation this run belongs to. Pass `'new'` to start a fresh conversation, ignoring any `conversation_id` already on `message_history`. If omitted, falls back to the most recent `conversation_id` on `message_history` or a freshly generated UUID7. | ||
| model: Optional model to use for this run, required if `model` was not set when creating the agent. |
There was a problem hiding this comment.
🔴 run_ag_ui documents conversation_id but lacks the parameter
The run_ag_ui function documents conversation_id in its docstring (pydantic_ai_slim/pydantic_ai/ag_ui.py:164) but the parameter is missing from the function signature (lines 128-148) and is not passed to adapter.run_stream() (lines 192-206). Users who follow the docstring and try to pass conversation_id='my-id' will get an unexpected keyword argument error.
Prompt for agents
The run_ag_ui function in pydantic_ai_slim/pydantic_ai/ag_ui.py documents conversation_id in its docstring (line 164) but the parameter is missing from the function signature (lines 128-148). It also needs to be forwarded to adapter.run_stream() in the call at lines 192-206. The fix requires adding conversation_id: str | None = None to the function signature (after deferred_tool_results on line 137), and adding conversation_id=conversation_id to the adapter.run_stream() call.
Was this helpful? React with 👍 or 👎 to provide feedback.
| OutputT = TypeVar('OutputT') | ||
|
|
||
|
|
||
| NEW_CONVERSATION: Literal['new'] = 'new' |
There was a problem hiding this comment.
Consider using Literal['new'] more precisely in the public API parameter type. The conversation_id parameter across Agent.run etc. is typed str | None, which means the 'new' sentinel is valid but not discoverable from the type signature. A more self-documenting type would be something like str | Literal['new'] | None (even though Literal['new'] is a subtype of str, the union still serves as documentation for IDE autocomplete and hover docs).
@DouweM — this is an API design choice worth considering: str | None is simpler but makes users rely on reading docs to discover 'new', while a Literal['new'] union makes it visible in type hints.
| output_type=output_type, | ||
| message_history=message_history, | ||
| deferred_tool_results=deferred_tool_results, | ||
| conversation_id=conversation_id, |
There was a problem hiding this comment.
Bug: further down in this method's on_complete callback (around line 780), a ModelRequest is constructed with run_id=graph_ctx.state.run_id but is missing conversation_id=graph_ctx.state.conversation_id:
messages.append(
_messages.ModelRequest(
parts, run_id=graph_ctx.state.run_id, timestamp=_utils.now_utc()
)
)Compare with _agent_graph.py _handle_final_result which correctly includes conversation_id. Messages appended via the run_stream path will silently lack the conversation ID, breaking the invariant that all messages produced during a run carry it.
|
|
||
| AGENT_NAME_BAGGAGE_KEY = 'gen_ai.agent.name' | ||
| RUN_ID_BAGGAGE_KEY = 'gen_ai.agent.call.id' | ||
| CONVERSATION_ID_BAGGAGE_KEY = 'gen_ai.conversation.id' |
There was a problem hiding this comment.
The CONVERSATION_ID_BAGGAGE_KEY is read from baggage in get_agent_run_baggage_attributes() below (lines 28–30), but it's never set in the baggage. In agent/__init__.py around line 1190, baggage is set for gen_ai.agent.name and gen_ai.agent.call.id but not for gen_ai.conversation.id. This means get_agent_run_baggage_attributes() will never return a conversation_id — the read side exists but the write side is missing. Either add _otel_set_baggage(CONVERSATION_ID_BAGGAGE_KEY, state.conversation_id) alongside the other two, or remove the dead read code here.
Docs Preview
|
… `run_stream` The `run_stream` flow builds a closing `ModelRequest` carrying the synthetic `final_result` `ToolReturnPart` so the history can be replayed. This message was getting `run_id` but not `conversation_id`, breaking correlation for any history captured from a streaming run. Also covers test snapshots for tests that only run on CI (skipped locally) and a missed history-processor test where the user-injected message intentionally has no `conversation_id`.
There was a problem hiding this comment.
🚩 run_ag_ui body likely also needs conversation_id forwarding
Beyond the signature bug reported in BUG-0002, the run_ag_ui function (starting at line 128) constructs an AGUIAdapter directly and calls run_stream on it. The conversation_id parameter needs to be forwarded not just through the signature but also into the adapter's run_stream call in the function body. The reviewer should verify the full function body when applying the fix to ensure end-to-end propagation.
(Refers to lines 128-148)
Was this helpful? React with 👍 or 👎 to provide feedback.
- Access `result.conversation_id` in the deferred-tool stream test so the `_run_result` branch of `StreamedRunResult.conversation_id` is exercised. - New AG-UI adapter test passes an explicit `conversation_id` to `run_stream_native`, covering the override-skip branch.
| output validators since output validators would expect an argument that matches the agent's output type. | ||
| message_history: History of the conversation so far. | ||
| deferred_tool_results: Optional results for deferred tool calls in the message history. | ||
| conversation_id: ID of the conversation this run belongs to. Pass `'new'` to start a fresh conversation, ignoring any `conversation_id` already on `message_history`. If omitted, falls back to the most recent `conversation_id` on `message_history` or a freshly generated UUID7. |
There was a problem hiding this comment.
The conversation_id docstring entry documents a parameter that doesn't exist in the function signature of handle_ag_ui_request. The same issue applies to run_ag_ui below (line 164).
Since AGUIAdapter already auto-populates conversation_id from RunAgentInput.threadId (via the conversation_id property), users of these convenience functions get it for free and don't need to pass it. Either add the parameter to the signatures and forward it (for users who want to override the AG-UI threadId), or remove these docstring entries to avoid confusion.
The docstrings for `handle_ag_ui_request` and `run_ag_ui` documented a `conversation_id` parameter that wasn't actually in the signatures. Add it to both signatures and forward it to the underlying adapter calls so callers can override `RunAgentInput.threadId` when needed.
| - [`run_id`][pydantic_ai.messages.ModelRequest.run_id] — unique per agent run; emitted on the OpenTelemetry agent run span as `gen_ai.agent.call.id`. | ||
| - [`conversation_id`][pydantic_ai.messages.ModelRequest.conversation_id] — shared across all runs that build on the same `message_history`; emitted as `gen_ai.conversation.id`. | ||
|
|
||
| A fresh `conversation_id` is generated on the first run, stamped onto every message produced by that run, and inherited by subsequent runs that pass the messages back via `message_history`. This means you can correlate traces from a multi-turn conversation in Logfire (or any OpenTelemetry backend) without tracking anything yourself — as long as the message history round-trips, the conversation ID does too. |
There was a problem hiding this comment.
Link to the Logfire.md docs
| To override or fork: | ||
|
|
||
| - Pass `conversation_id='<your-id>'` to use an ID from your own application (e.g. a chat thread ID stored in your database). | ||
| - Pass `conversation_id='new'` to start a fresh conversation that ignores any `conversation_id` already on `message_history` — useful for branching off an existing thread without making the caller generate an ID. |
There was a problem hiding this comment.
I don't love making new a special value, although we do that with auto in other places. Any better ideas to comunicate this without adding an extra arg which I don't wanna do?
| assert forked.conversation_id != result1.conversation_id | ||
| ``` | ||
|
|
||
| The [`AGUIAdapter`][pydantic_ai.ui.ag_ui.AGUIAdapter] auto-populates `conversation_id` from `RunAgentInput.threadId`, and [`VercelAIAdapter`][pydantic_ai.ui.vercel_ai.VercelAIAdapter] auto-populates it from the top-level chat `id` field of the Vercel AI SDK request body, so frontends using these protocols get correlation for free. |
There was a problem hiding this comment.
Just say "UI adapters" and link to the overview page.
`feat: add conversation_id for cross-run correlation (pydantic#5251)` added a `conversation_id` field to `ModelRequest`/`ModelResponse`. Match it with `IsStr()` (mirroring `tests/models/test_xai.py` and others) so the end-to-end snapshots accept the runtime-generated UUID.
gen_ai.conversation.idspan attribute. #3925Adds a stable identifier shared across all agent runs that build on the same
message_history, emitted as thegen_ai.conversation.idOpenTelemetry span attribute on theagent_runspan. Resolves from explicitconversation_id=kwarg (with'new'sentinel to fork), priorconversation_idonmessage_history, or a fresh UUID7. Surfaces onRunContextfor capability hooks and on every producedModelRequest/ModelResponseso it round-trips through serialization.AGUIAdapterauto-populatesconversation_idfromRunAgentInput.threadId;VercelAIAdapterauto-populates from the top-level Vercel AI chatid. Both are confirmed against their respective protocol docs (AG-UI:threadIdis the long-lived conversation thread; Vercel AI: top-levelidis the chat ID perprepareSendMessagesRequest).Resolution priority for the run's
conversation_id:conversation_id=...argumentconversation_id='new'→ fresh UUID7 (forks off existing history)conversation_idonmessage_historyrun_id, so the two identifiers stay distinct concepts)Old histories upgrade naturally — a new run gets a fresh
conversation_idand stamps it forward; from the next run onward, rule (3) does the right thing. Deferred-tool round-trips work for free because the prior run's stamped messages already carry the ID.The bigger
Session/Conversationcontainer concept (à la OpenAI Agents SDK) is intentionally deferred — see design notes for the rationale and follow-up surface area (parent_run_id/lineage, Agent-level factory, AG-UI runId mapping, OpenAI Conversations interaction with #5224).Checklist