Skip to content

fix: pair tool outputs with their call by call_id when copying a chat context - #2531

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
port/chat-context-call-id-pairing
Open

rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
port/chat-context-call-id-pairing

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Ports livekit/agents#7330.

Summary

  • Pair nameless function outputs with surviving calls by callId when filtering a chat context by tools.
  • Record realtime calls beside their outputs and upsert session history to avoid duplicate calls.
  • Add the source regression coverage and a patch changeset for @livekit/agents.
Source diff coverage
  • livekit-agents/livekit/agents/llm/chat_context.py: Adapted to agents/src/llm/chat_context.ts; ports call-ID pairing in copy({ toolCtx }) and the internal upsert behavior required by the voice changes.
  • livekit-agents/livekit/agents/voice/agent_activity.py: Adapted to agents/src/voice/agent_activity.ts; records each realtime call with its output using JS event arrays and naming.
  • livekit-agents/livekit/agents/voice/agent_session.py: Adapted to agents/src/voice/agent_session.ts; upserts tool items so execution-started calls are not duplicated.
  • tests/test_chat_ctx.py: Adapted to agents/src/llm/chat_context.test.ts; ports all five new tool-filtering regression cases.
  • tests/test_realtime_tool_call_created_at.py: Adapted to agents/src/voice/realtime_tool_output_commit.test.ts; ports the no-duplicate normal execution assertion and rejected-arguments call/output pairing case into the existing JS realtime harness.
  • Not applicable: none. Every source file has a target counterpart and is covered.

Verification

  • ./node_modules/.bin/vitest run agents (2,724 passed, 5 skipped)
  • ./node_modules/.bin/turbo run build
  • ./node_modules/.bin/eslint -f unix "agents/src/**/*.ts" "plugins/*/src/**/*.{ts,js}" "examples/src/**/*.ts"
  • ./node_modules/.bin/prettier --check "**/src/**/*.{ts,tsx,md,json}"
  • ./node_modules/.bin/turbo run api:check --filter=@livekit/agents
  • cue-cli runtime validation was attempted after building, but the configured LiveKit worker registration was rejected with HTTP 401, so no remote framework-event session could be driven.

Ported from livekit/agents#7330

Original PR description

Problem: ChatContext.copy(tools=...) filtered function items by tool name, but FunctionCallOutput.name is optional and defaults to "", so a name-less output was removed while its FunctionCall was kept. group_tool_calls then removed the orphaned call too, and a completed tool exchange disappeared from the request, leaving the user message that asked for the tool with no answer.

Fix: An output is tested by its own name when it has one, and by call_id against the surviving calls when it has none, so a named output behaves exactly as before. The realtime completion path also records a tool call next to its output, in the agent context and in session.history, because a call rejected before execution never reaches the execution-started callback that was its only record.

Addresses #7324.

Context for reviewing and coding agents

How to see it

test_copy_keeps_a_tool_output_with_no_name builds a user message, a FunctionCall for get_weather, and a FunctionCallOutput with no name, then calls copy(tools=["get_weather"]). On main the copy holds ['message', 'function_call'] and the anthropic format holds only [('user', ['text'])].

test_realtime_records_the_call_of_a_tool_that_never_ran drives a fake realtime turn whose call carries arguments="[1, 2, 3]", which parse_function_arguments rejects as a non-object. Without the agent_activity.py change the agent context and session.history each hold a function_call_output for call_1 and no function_call. test_realtime_tool_call_created_at_is_stamped_at_execution_start covers the other direction: without the agent_session.py change the normal path records ['call_1', 'call_1'] in session.history.

Why a realtime output can have no call

Both reply tasks pass a tool_execution_started_cb into perform_tool_executions, and they depend on it to different degrees. The pipeline task uses it only to stamp created_at and to feed RunResult; it writes history separately, rebuilding new_calls from every entry in tool_output.output (voice/agent_activity.py:3941, :3969). The realtime task had the callback as the single place a call reached the chat context (:4439), while the output was written on completion (:4645). perform_tool_executions produces an output without reaching that callback in three branches: an unknown tool name (voice/generation.py:884), an unknown tool type (:904), and arguments that llm_utils.parse_function_arguments cannot repair into an object (:929).

Why the fix is at the completion site

Hoisting the callback above the validation in perform_tool_executions would change the pipeline as well, and the callback means execution start — it stamps created_at there, which test_realtime_tool_call_created_at_is_stamped_at_execution_start asserts. Removing the callback's own chat-context write and recording the call only on completion fails test_realtime_tool_results_preserved_and_synced_when_interrupted, because the interrupted branch inserts outputs alone and relies on that write (:4599); it also makes a call visible while its tool runs. Guarding the new write with a presence test would reimplement _upsert_item, which already replaces by id or inserts by created_at, and the unconditional call additionally stores the object after perform_tool_executions rewrites arguments to canonical JSON.

The two sinks for a tool call

AgentActivity records a realtime call in the agent's own chat context and, through AgentSession._tool_items_added, in the session history that session.history returns (voice/agent_session.py:801). Both needed the call. _tool_items_added inserted its items, so passing an already-recorded call would duplicate it; it now upserts each item, and its four other call sites pass items that are not in the context yet.

Blast radius

copy(tools=...) reaches a provider request through voice/agent.py:92 (Agent(chat_ctx=…)), voice/agent.py:245 (update_tools) and voice/agent.py:269 (update_chat_ctx). AgentActivity.update_chat_ctx applies the filter itself at voice/agent_activity.py:771, so a caller that passes an unfiltered context still gets it. A copy() call with no tools argument is unaffected, because the filter does not run. An output whose call_id matches no call is still dropped when it has no name, which to_provider_format already did at llm/_provider_format/utils.py:137.

Why the plugin cannot fill the name in

The OpenAI realtime plugin builds a name-less output at livekit-plugins-openai/livekit/plugins/openai/realtime/utils.py:267. The wire type RealtimeConversationItemFunctionCallOutput has no name field, so the plugin would have to look the call up to supply one. The field is optional on the model, so any caller may leave it unset, and call_id is the only identity every producer supplies.

The claim in the issue about the realtime path

#7324 states that name-less outputs enter a history through openai/realtime/utils.py:267 and voice/agent_activity.py:2171. That chain does not hold. _on_remote_item_added returns early when the item id is already in the local context, and for a tool the framework runs itself the named output is inserted first, under the same item id that livekit_item_to_openai_item sent to the server. The issue's end-to-end run calls _on_remote_item_added by hand with a fresh id, which skips that test. The defect in copy() is reachable through the public API regardless, because name is optional.

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from a team as a code owner September 18, 2026 07:19
@changeset-bot

changeset-bot Bot commented Sep 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7509935

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-krisp Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-meta Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

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

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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants