fix(proto): one ChatItem serializer that carries every declared field - #6756
Open
u9g wants to merge 3 commits into
Open
fix(proto): one ChatItem serializer that carries every declared field#6756u9g wants to merge 3 commits into
u9g wants to merge 3 commits into
Conversation
Both copies of the ChatItem mapping now sit in one file, which is the point: they were written twice, in voice and in telemetry, and had drifted. The move is otherwise verbatim, except that the voice copy dispatches on ChatItem's discriminator rather than isinstance. Importing the classes would pull llm in, and llm reaches telemetry, so the module could not stay a leaf.
The voice copy dropped FunctionCallOutput.id, created_at on every item, transcript_confidence and extra on messages, and the speaking-at metrics; the telemetry copy dropped llm_node_tps and llm_node_ttfs and truncated timestamps to milliseconds. Each was missing whatever the other had. encode_by_name reads the fields the proto declares off the source object, so one implementation serves both paths and a field added to the schema is carried without editing a call site. Message content follows the telemetry copy, one entry per part, because the proto field is repeated and joining the parts collapsed them into one.
Every ChatItem, model usage and metrics source is saturated, encoded, then walked against the proto descriptor; anything left unset is a drop. Timestamps in the fixture are sub-millisecond and message content is multi-part, so a serializer that truncates or joins fails rather than passing on a value that hides the difference.
u9g
force-pushed
the
fix/remote-session-chat-item-proto-fields
branch
from
August 9, 2026 14:53
c8aa129 to
7aba481
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
_chat_item_to_protoinvoice/remote_session.pydroppedFunctionCallOutput.id. The proto declares the field, the sibling serializer intelemetry/traces.pyset it, agents-js set it, and every other branch of the same function setid. The identical omission ofnamefrom this constructor was fixed once already in #6491.Nothing reads that id today — livekit-cli renders function call outputs by name/output/is_error — so it was silent data loss rather than a visible break.
Why there was a second copy to disagree with
Rather than add the one field, I measured both serializers: saturate every
ChatItemfield with a non-default value, serialize, then walk the proto descriptor for whatever came back unset. On main:Each copy was missing a different subset.
remote_sessionnever got the speaking-at timestamps because its_metrics_to_protosplatted scalars only and those two areTimestamps;traces.pynever got tps/ttfs because #6373 added them to the other list. Neither author was wrong — the mapping was written twice and drifted wherever the other one didn't.The three commits
refactor: move proto serialization into its own module— both copies intolivekit/agents/_proto.py, otherwise verbatim. A leaf: it imports neitherllm,voicenortelemetryat module scope, so anything that puts a ChatItem on the wire can share it. The one non-mechanical part is that the voice copy now dispatches onChatItem's discriminator rather thanisinstance; importing the classes pulls inllm,llmreachestelemetry, and the import cycles.fix(proto): carry every field the wire format declares—encode_by_namereads the fields the proto declares off the source object, so one implementation serves both paths and a field added to the schema is carried without editing a call site._session_usage_to_proto's five variants collapse to a table over the same helper.test(proto): fail when a declared field is dropped— the guard, below.Behavior changes
FromMillisecondstoFromNanoseconds.created_atistime.time(), so sub-millisecond components were being truncated.contentis one entry per part rather than the parts joined with\n. The proto field is repeated; joining collapsed them. This is whattraces.pyand agents-js already sent — the RemoteSession path is the one that changes.Tests
tests/test_chat_item_proto_coverage.pysaturates every ChatItem, model usage and metrics source, encodes it, then walks the result against the proto descriptor; anything left unset is a drop. That is what makesencode_by_namesafe, since a field it cannot find on the source is skipped rather than raised — verified against a deliberately stale source, which the guard reports field by field.Fixture timestamps are sub-millisecond and message content is multi-part, so a serializer that truncates or joins fails rather than passing on a value that hides the difference.
1885 passed. The 15 failures in
test_realtime/test_openai_realtime_model.pyandtest_realtime/test_xai_realtime_model.pyare pre-existing: a pristineorigin/maincheckout in the same environment fails the same 15.encode_by_namecosts mypy coverage on the copied assignments, which is the deliberate trade — the guard covers the same ground at test time rather than in the editor. Where it collided with typing outright, atModelUsage's oneof, the code usesCopyFromrather than a dict splat, soscripts/check_types.pystill passes clean.agents-js
livekit/agents-js#2247 does the same there, and its telemetry log now matches this one field for field — verified by encoding the same five items through both SDKs and diffing the JSON.
Follow-ups, not in this PR
MetricsReporthas noplayback_latency, so neither SDK can carry it; agents-js was emitting it into telemetry only because its hand-written types had no link to the schema. It needs a field in livekit/protocol.The
function_tools_executedhandler still buildsFunctionCallOutputwithoutid(remote_session.py:528), as does the agents-js equivalent. Consistent across SDKs, so left alone.