Skip to content

Commit df5f42c

Browse files
committed
fix(voice): commit realtime tool outputs to the agent chat context
The realtime tool-execution path pushed FunctionCallOutput items only into the copy sent to the provider and into `session.history`, never into `agent._chatCtx`. Since the matching FunctionCall *is* added there (by onToolExecutionStarted), the agent context was left with dangling tool calls that had no results. That broke action-aware history summarization — `ChatContext._summarize` renders function calls and outputs so the summarizer can distill knowledge gained from tool results — and it can trip provider validation through the handoff merge in `Agent.updateAgent`. Python already does both (`agent_activity.py`: `_upsert_item` on the agent ctx plus `_tool_items_added` on the session); this restores parity.
1 parent 7d8cd69 commit df5f42c

3 files changed

Lines changed: 218 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/agents': patch
3+
---
4+
5+
Commit realtime tool call outputs to the agent chat context. Previously the realtime path only sent them to the provider and to `session.history`, leaving `agent.chatCtx` with function calls that had no matching outputs — which broke action-aware history summarization and agent handoff merges.

agents/src/voice/agent_activity.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3951,9 +3951,14 @@ export class AgentActivity implements RecognitionHooks {
39513951
const chatCtx = realtimeSession.chatCtx.copy();
39523952
chatCtx.items.push(...functionToolsExecutedEvent.functionCallOutputs);
39533953

3954-
this.agentSession._toolItemsAdded(
3955-
functionToolsExecutedEvent.functionCallOutputs as FunctionCallOutput[],
3956-
);
3954+
// Also commit the outputs to the agent's own chat context. The FunctionCall items were
3955+
// added by onToolExecutionStarted, so without this the agent ctx keeps dangling calls with
3956+
// no results — breaking history summarization (which distills tool results) and agent
3957+
// handoff merges. `agentSession.history` is updated separately by `_toolItemsAdded`.
3958+
const toolCallOutputs =
3959+
functionToolsExecutedEvent.functionCallOutputs as FunctionCallOutput[];
3960+
this.agent._chatCtx.insert(toolCallOutputs);
3961+
this.agentSession._toolItemsAdded(toolCallOutputs);
39573962

39583963
// If the realtime model auto-generates the tool reply, install a
39593964
// placeholder so the active RunResult waits for that reply.
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import type { AudioFrame } from '@livekit/rtc-node';
5+
import { ReadableStream } from 'node:stream/web';
6+
import { describe, expect, it, vi } from 'vitest';
7+
import { ChatContext, type ChatItem, FunctionCall } from '../llm/chat_context.js';
8+
import {
9+
type GenerationCreatedEvent,
10+
type MessageGeneration,
11+
type RealtimeCapabilities,
12+
RealtimeModel,
13+
RealtimeSession,
14+
} from '../llm/realtime.js';
15+
import { type ToolChoice, ToolContext, tool } from '../llm/tool_context.js';
16+
import { initializeLogger } from '../log.js';
17+
import { Agent } from './agent.js';
18+
import { AgentSession } from './agent_session.js';
19+
20+
initializeLogger({ pretty: false, level: 'silent' });
21+
22+
function emptyStream<T>(): ReadableStream<T> {
23+
return new ReadableStream<T>({
24+
start(controller) {
25+
controller.close();
26+
},
27+
});
28+
}
29+
30+
function oneItemStream<T>(item: T): ReadableStream<T> {
31+
return new ReadableStream<T>({
32+
start(controller) {
33+
controller.enqueue(item);
34+
controller.close();
35+
},
36+
});
37+
}
38+
39+
const TOOL_CALL_ID = 'call_lookup_order';
40+
41+
/**
42+
* Emits a single tool call on the first generation, then plain text replies. The
43+
* second generation matters for `autoToolReplyGeneration: false`, where the
44+
* activity schedules its own reply speech after the tool results are committed.
45+
*/
46+
class FakeRealtimeSession extends RealtimeSession {
47+
private _chatCtx = ChatContext.empty();
48+
private _tools = ToolContext.empty();
49+
private generations = 0;
50+
51+
get chatCtx(): ChatContext {
52+
return this._chatCtx;
53+
}
54+
55+
get tools(): ToolContext {
56+
return this._tools;
57+
}
58+
59+
async updateInstructions(_instructions: string): Promise<void> {}
60+
61+
async updateChatCtx(chatCtx: ChatContext): Promise<void> {
62+
this._chatCtx = chatCtx.copy();
63+
}
64+
65+
async updateTools(tools: ToolContext): Promise<void> {
66+
this._tools = tools.copy();
67+
}
68+
69+
updateOptions(_options: { toolChoice?: ToolChoice | null }): void {}
70+
71+
pushAudio(_frame: AudioFrame): void {}
72+
73+
async generateReply(): Promise<GenerationCreatedEvent> {
74+
const isFirst = this.generations++ === 0;
75+
76+
const message: MessageGeneration = {
77+
messageId: `message-${this.generations}`,
78+
textStream: oneItemStream(isFirst ? 'Let me check that.' : 'Your order ships tomorrow.'),
79+
audioStream: emptyStream(),
80+
modalities: Promise.resolve(['text']),
81+
};
82+
83+
return {
84+
messageStream: oneItemStream(message),
85+
functionStream: isFirst
86+
? oneItemStream(
87+
FunctionCall.create({ callId: TOOL_CALL_ID, name: 'lookup_order', args: '{}' }),
88+
)
89+
: emptyStream<FunctionCall>(),
90+
userInitiated: true,
91+
responseId: `response-${this.generations}`,
92+
};
93+
}
94+
95+
async commitAudio(): Promise<void> {}
96+
97+
async clearAudio(): Promise<void> {}
98+
99+
async interrupt(): Promise<void> {}
100+
101+
async truncate(): Promise<void> {}
102+
}
103+
104+
class FakeRealtimeModel extends RealtimeModel {
105+
readonly activeSession: FakeRealtimeSession;
106+
107+
constructor(capabilitiesOverrides: Partial<RealtimeCapabilities> = {}) {
108+
const capabilities: RealtimeCapabilities = {
109+
messageTruncation: false,
110+
turnDetection: false,
111+
userTranscription: false,
112+
autoToolReplyGeneration: false,
113+
audioOutput: false,
114+
manualFunctionCalls: false,
115+
midSessionChatCtxUpdate: true,
116+
midSessionInstructionsUpdate: true,
117+
midSessionToolsUpdate: true,
118+
perResponseToolChoice: false,
119+
...capabilitiesOverrides,
120+
};
121+
super(capabilities);
122+
this.activeSession = new FakeRealtimeSession(this);
123+
}
124+
125+
get model(): string {
126+
return 'fake-realtime';
127+
}
128+
129+
session(): RealtimeSession {
130+
return this.activeSession;
131+
}
132+
133+
async close(): Promise<void> {}
134+
}
135+
136+
function createAgent(): Agent {
137+
return new Agent({
138+
instructions: 'test',
139+
tools: {
140+
lookup_order: tool({
141+
description: 'Look up an order',
142+
execute: async () => 'shipping tomorrow',
143+
}),
144+
},
145+
});
146+
}
147+
148+
async function runToolCall(capabilities: Partial<RealtimeCapabilities>) {
149+
const llm = new FakeRealtimeModel(capabilities);
150+
const session = new AgentSession({ llm, vad: null, turnHandling: { turnDetection: null } });
151+
const agent = createAgent();
152+
153+
await session.start({ agent });
154+
try {
155+
await session.generateReply().waitForPlayout();
156+
// The tool runs as a background speech, so the outputs land after playout.
157+
await vi.waitFor(() =>
158+
expect(agent.chatCtx.items.some((item) => item.type === 'function_call_output')).toBe(true),
159+
);
160+
} finally {
161+
await session.close();
162+
}
163+
164+
return { agent, session };
165+
}
166+
167+
describe('Realtime tool output commit', () => {
168+
// Regression: the realtime path pushed tool outputs only into the copy sent to
169+
// the provider and into `session.history`, never into `agent._chatCtx`. That
170+
// left the agent context with a `function_call` and no matching output, so
171+
// history summarization (which distills tool results) and handoff merges saw a
172+
// dangling call. Python commits both (agent_activity.py `_upsert_item`).
173+
it('commits tool outputs to the agent chat context', async () => {
174+
const { agent } = await runToolCall({ autoToolReplyGeneration: true });
175+
176+
const calls = agent.chatCtx.items.filter((item) => item.type === 'function_call');
177+
const outputs = agent.chatCtx.items.filter((item) => item.type === 'function_call_output');
178+
179+
expect(calls.map((c) => c.callId)).toEqual([TOOL_CALL_ID]);
180+
expect(outputs.map((o) => o.callId)).toEqual([TOOL_CALL_ID]);
181+
expect(outputs[0]?.output).toBe(JSON.stringify('shipping tomorrow'));
182+
183+
// The output must follow its call so summarization renders them in order.
184+
const items = agent.chatCtx.items;
185+
expect(items.indexOf(outputs[0]!)).toBeGreaterThan(items.indexOf(calls[0]!));
186+
});
187+
188+
it('commits tool outputs to the agent chat context when the model needs an explicit reply', async () => {
189+
const { agent } = await runToolCall({ autoToolReplyGeneration: false });
190+
191+
const outputs = agent.chatCtx.items.filter((item) => item.type === 'function_call_output');
192+
expect(outputs.map((o) => o.callId)).toEqual([TOOL_CALL_ID]);
193+
});
194+
195+
it('keeps session history in sync with the agent chat context', async () => {
196+
const { agent, session } = await runToolCall({ autoToolReplyGeneration: true });
197+
198+
const toolItemIds = (ctx: { items: readonly ChatItem[] }) =>
199+
ctx.items
200+
.filter((item) => item.type === 'function_call' || item.type === 'function_call_output')
201+
.map((item) => `${item.type}:${item.callId}`);
202+
203+
expect(toolItemIds(agent.chatCtx)).toEqual(toolItemIds(session.history));
204+
});
205+
});

0 commit comments

Comments
 (0)