feat(voice): add public add_conversation_item method to AgentSession - #7099
feat(voice): add public add_conversation_item method to AgentSession#7099wardandr wants to merge 3 commits into
add_conversation_item method to AgentSession#7099Conversation
e4cd7a1 to
653abf1
Compare
Add `AgentSession.add_conversation_item(item)` so external code can insert a ChatMessage into both the session history and the active agent's chat context, emitting `conversation_item_added` the same way the internal audio pipeline does. The call is idempotent: duplicate message IDs are detected via `ChatContext.get_by_id` and silently skipped. Closes livekit#7085
Make the method async and uniform across pipelines: - waterfall: unchanged β agent context + session history + event - realtime: push-then-commit via `_rt_session.update_chat_ctx`; only a successful push commits locally, so a failed push leaves the session untouched and retrying with the same item id works - realtime models without a mutable chat context raise `RealtimeError` Document that items added before `start()` are never backfilled into the agent's context, and pin that contract in tests. Adds realtime tests with FakeRealtimeModel: provider push, non-mutable raise, push failure + retry, and realtime-path dedup.
653abf1 to
cdfebce
Compare
β¦omicity Serialize concurrent callers with a dedicated lock and hold the activity lock across the provider await so agent handoffs cannot split an addition across old and new contexts. Track in-flight item IDs so realtime provider echoes that arrive before update_chat_ctx returns are suppressed by _on_remote_item_added rather than inserted as duplicate placeholders. Add concurrency, handoff-atomicity, agent-level realtime override, and echo-before-failure tests. π€ Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori <contact@tilework.tech>
There was a problem hiding this comment.
Devin Review found 3 new potential issues.
1 flag not posted on this PR by your GitHub settings β view it in Devin Review. (Configure)
| if agent._chat_ctx.get_by_id(item.id) is None: | ||
| agent._chat_ctx.insert(item) | ||
| self._conversation_item_added(item) |
There was a problem hiding this comment.
π‘ Concurrent transcript duplicates conversation item
When a matching final transcript lands during update_chat_ctx, add_conversation_item commits the same ID again. History and listeners receive duplicates.
Prompt for agents
Make the realtime commit phase re-check session history after update_chat_ctx returns. If another pipeline path committed the same ID during the await, avoid inserting or emitting it again and return the idempotent result. Keep the agent context, session history, and documented return value coherent under this race.
Was this helpful? React with π or π to provide feedback.
There was a problem hiding this comment.
This race is already prevented. The method holds _activity_lock across the entire transaction (dedup check β provider push β local commit). The pipeline's _user_turn_completed_task path acquires the same lock before committing a transcript, so the two paths are serialized β a matching final transcript cannot land and commit while add_conversation_item is mid-flight.
| self._locally_pushed_conversation_item_ids.add(item.id) | ||
| try: | ||
| await rt_session.update_chat_ctx(candidate) | ||
|
|
||
| if agent._chat_ctx.get_by_id(item.id) is None: | ||
| agent._chat_ctx.insert(item) | ||
| self._conversation_item_added(item) |
There was a problem hiding this comment.
π΄ Fallback swaps drop added items
During a fallback swap, update_chat_ctx reports success without sending the item. add_conversation_item commits locally, but the replacement model never receives it.
Prompt for agents
Coordinate add_conversation_item with _FallbackRealtimeSession swaps. The fallback adapter currently drops update_chat_ctx calls while _swapping because ordinary callers already changed the agent context, but add_conversation_item uses push-then-commit and has not changed it yet. Ensure the item is included in the swap replay or make the update wait/retry against the replacement child before committing local state.
Was this helpful? React with π or π to provide feedback.
There was a problem hiding this comment.
This is a pre-existing limitation of _FallbackRealtimeSession's update_chat_ctx contract β it silently drops calls during a swap. That affects every caller of update_chat_ctx, not just add_conversation_item. Fixing the fallback adapter's swap semantics is out of scope for this PR; it would require changes to the _FallbackRealtimeSession internals.
| await rt_session.update_chat_ctx(candidate) | ||
|
|
||
| if agent._chat_ctx.get_by_id(item.id) is None: | ||
| agent._chat_ctx.insert(item) | ||
| self._conversation_item_added(item) | ||
| return True |
There was a problem hiding this comment.
π‘ Rejected updates appear successfully added
When update_chat_ctx absorbs a provider rejection, add_conversation_item returns True and commits locally. The live model never sees the item.
Prompt for agents
Define a provider-level acknowledgement contract that lets add_conversation_item determine whether its specific item was accepted. OpenAI update_chat_ctx currently logs per-item rejections and returns normally. Do not commit or return True unless the requested item reached the provider; otherwise raise RealtimeError and retain retry semantics.
Was this helpful? React with π or π to provide feedback.
There was a problem hiding this comment.
Same as above β this is a pre-existing contract issue with the RealtimeSession interface. OpenAI's update_chat_ctx absorbs per-item rejections and returns normally, which affects all callers. Defining a provider-level acknowledgement contract would require changes to the RealtimeSession base class, which is out of scope here. add_conversation_item follows the same contract as every other internal caller of update_chat_ctx.
Summary
Adds
AgentSession.add_conversation_item(item: ChatMessage) -> boolβ a public, async method to insert aChatMessageinto the session history and the active agent's chat context, emittingconversation_item_addedthe same way the internal audio pipeline does. Works uniformly across waterfall (STT β LLM β TTS) and realtime pipelines.session.history(session-levelChatContext) and, when an agent is running, into its chat contextconversation_item_addedso event listeners (transcript capture, analytics, etc.) observe the item normally_rt_session.update_chat_ctx, and only a successful push commits locally. A failed push raisesRealtimeErrorand leaves the session untouched, so retrying with the same item id worksRealtimeErrorbefore any mutationChatContext.get_by_idand silently skipped (returnsFalse)session.start()reach session history and the event only; they are never backfilled into the agent's context once it startsUse cases
Current workaround this replaces
New public API
Closes #7085
Test plan
test_add_conversation_item_appears_in_historyβ message retrievable fromsession.historytest_add_conversation_item_emits_eventβconversation_item_addedevent fires with correct payloadtest_add_conversation_item_visible_to_agentβ message appears inagent.chat_ctxtest_add_conversation_item_dedup_by_idβ second call with same ID returns False; no duplicate event or agent-ctx entrytest_add_conversation_item_before_startβ history + event fire pre-start; pins that the item is never backfilled into the agent's contexttest_add_conversation_item_realtime_pushes_to_rt_sessionβ item reaches the live realtime session context, local history, agent ctx, and eventtest_add_conversation_item_realtime_non_mutable_raisesβ raisesRealtimeErrorwith zero side effectstest_add_conversation_item_realtime_push_failure_leaves_state_cleanβ failed push mutates nothing; retry with same id succeedstest_add_conversation_item_realtime_dedup_by_idβ dedup applies on the realtime pathmain; lint, format, and type-check (livekit.agents) pass