Skip to content

feat: public API to append a user-role conversation item and emit conversation_item_added #7085

Description

@wardandr

Feature Type

Would make my life easier

Feature Description

Summary

We need a public way to add a user-role ChatMessage to a running AgentSession and have it behave like a normal conversation item, including emitting conversation_item_added.

Right now, the only way I can find to do this correctly is to mirror what LiveKit does internally:

self._agent._chat_ctx.items.append(user_message)
self._session._conversation_item_added(user_message)

This is also exactly what LiveKit does internally. For example: the session-closing path in agent_activity.py:2454–2455.

The problem is that both of these are private API.

Why appending to agent._chat_ctx isn't enough

AgentSession._conversation_item_added does more than emit the event. It also inserts the item into the session-level _chat_ctx and logs it.

So if an application only appends to agent._chat_ctx, the LLM context may contain the message, but the session context doesn't, and neither do any conversation_item_added listeners.

Subscribing to conversation_item_added is public API. There just doesn't seem to be a corresponding public API for adding an item and causing that event to fire.

Use cases

We're running outbound SIP agents, where legitimate user input can sometimes arrive outside the normal VAD/STT turn pipeline.

  1. Late STT finals. A final transcript can arrive after the turn has already committed. Turn commits with end_of_turn_probability: null when STT transcript arrives after turn flush, splitting one utterance into two turns #6504 appears to cover part of this race upstream. When this happens, we need to insert the user text ourselves so the LLM context and transcript capture stay consistent.

  2. DTMF-derived input. We collect things like dates and account numbers via keypad. In some cases, that input should appear in conversation history as user input.

  3. Out-of-band text. For example, SIP INFO or caller data pushed in by the backend that should become part of the conversation record.

What we're doing today

We currently have two defensive injection sites using the private APIs above.

Both use two layers of deduplication because there's a race where LiveKit may have inserted the message itself before our fallback runs:

msg_id = getattr(new_message, "id", None)
if msg_id and self._chat_ctx.get_by_id(msg_id) is not None:
    return

text = (getattr(new_message, "text_content", None) or "").strip()
if text and _chat_ctx_has_user_text(self._chat_ctx, text):
    return

self._chat_ctx.items.append(new_message)
self.session._conversation_item_added(new_message)

This works, but it makes upgrading livekit-agents unnecessarily risky. We pin the package exactly and have tripwire tests around _conversation_item_added, since a private API change here could otherwise show up as a mid-call production failure.

It also means we can get blocked from upgrading for unrelated fixes because we're depending on an implementation detail.

Proposed API

I'd be happy with either of these shapes, and happy to submit a PR for whichever fits the project better.

Minimal API

Expose the existing behavior publicly, something like:

session.add_conversation_item(message)

This would add the item to the appropriate context(s) and emit conversation_item_added, ideally idempotently by message ID.

This seems like the smallest change and maps pretty directly to what the framework already does internally.

Higher-level API

Alternatively:

session.add_user_message(text, ...)

This could construct the ChatMessage, add it to the active agent's context, update the session context, dedupe, and emit the event.

It's more convenient, but also a little more opinionated.

Alternatives we've looked at

session.say(..., add_to_chat_ctx=True) solves the equivalent problem for agent speech, but there doesn't appear to be a user-role equivalent.

Agent.update_chat_ctx() replaces the whole context and updates the realtime session, but it doesn't emit conversation_item_added, so listeners doing things like transcript capture won't see the inserted message.

That leaves the private:

agent._chat_ctx.items.append(...)
session._conversation_item_added(...)

pair as the only option we've found.

Environment

  • livekit-agents==1.7.1
  • Python 3.12
  • Outbound SIP
  • STT → LLM → TTS pipeline
  • Manual turn detection on the opener path
  • Permalinks above are pinned to the livekit-agents@1.7.1 commit (6e3af31)

Happy to open a PR for either API shape.

Workarounds / Alternatives

No response

Additional Context

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions