Skip to content

feat: add conversation_id for cross-run correlation#5251

Merged
DouweM merged 9 commits intomainfrom
conversation-id
Apr 30, 2026
Merged

feat: add conversation_id for cross-run correlation#5251
DouweM merged 9 commits intomainfrom
conversation-id

Conversation

@DouweM
Copy link
Copy Markdown
Collaborator

@DouweM DouweM commented Apr 29, 2026

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 conversation_id from RunAgentInput.threadId; VercelAIAdapter auto-populates from the top-level Vercel AI chat id. Both are confirmed against their respective protocol docs (AG-UI: threadId is the long-lived conversation thread; Vercel AI: top-level id is the chat ID per prepareSendMessagesRequest).

Resolution priority for the run's conversation_id:

  1. Explicit conversation_id=... argument
  2. conversation_id='new' → fresh UUID7 (forks off existing history)
  3. Most recent conversation_id on message_history
  4. Fresh UUID7 (independent from this run's run_id, so the two identifiers stay distinct concepts)

Old histories upgrade naturally — a new run gets a fresh conversation_id and 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 / Conversation container 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

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • No breaking changes in accordance with the version policy.
  • PR title is fit for the release changelog.

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.
@github-actions github-actions Bot added size: L Large PR (501-1500 weighted lines) feature New feature request, or PR implementing a feature (enhancement) labels Apr 29, 2026
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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment thread pydantic_ai_slim/pydantic_ai/agent/__init__.py
Comment on lines +87 to 88
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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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().
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +164 to 165
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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

OutputT = TypeVar('OutputT')


NEW_CONVERSATION: Literal['new'] = 'new'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

DouweM added 3 commits April 29, 2026 16:14
… `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`.
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 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)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

DouweM added 3 commits April 29, 2026 16:37
- 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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment thread docs/message-history.md Outdated
- [`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.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link to the Logfire.md docs

Comment thread docs/message-history.md
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.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread docs/message-history.md Outdated
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.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just say "UI adapters" and link to the overview page.

@DouweM DouweM merged commit 09428c9 into main Apr 30, 2026
47 checks passed
@DouweM DouweM deleted the conversation-id branch April 30, 2026 23:37
eliasaronson added a commit to eliasaronson/pydantic-ai that referenced this pull request May 4, 2026
`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature request, or PR implementing a feature (enhancement) size: L Large PR (501-1500 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add conversation/session concept and add gen_ai.conversation.id span attribute.

1 participant