Skip to content

Commit e48ed18

Browse files
feat: add handoff context summarization example
Demonstrates three strategies for passing context between agents: 1. Structured userData with JSON serialization 2. Chat context copy/truncate for conversational continuity 3. LLM-powered summarization before handoff Co-Authored-By: kat <kat@livekit.io>
1 parent b11c863 commit e48ed18

1 file changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
/**
6+
* Example: Summarizing context during agent handoffs.
7+
*
8+
* This example demonstrates three strategies for passing context between agents
9+
* during a handoff:
10+
*
11+
* 1. **Structured userData** - Store key facts in a typed object and serialize
12+
* it so the next agent can read a compact snapshot.
13+
* 2. **Chat context copy / truncate** - Carry the previous agent's recent
14+
* conversation history into the new agent for continuity.
15+
* 3. **LLM-powered summarization** - Use the LLM to compress older conversation
16+
* turns into a short summary before handing off.
17+
*
18+
* Run with:
19+
* npx tsx examples/src/handoff_context_summarization.ts dev
20+
*/
21+
import {
22+
type JobContext,
23+
type JobProcess,
24+
WorkerOptions,
25+
cli,
26+
defineAgent,
27+
llm,
28+
voice,
29+
} from '@livekit/agents';
30+
import * as deepgram from '@livekit/agents-plugin-deepgram';
31+
import * as livekit from '@livekit/agents-plugin-livekit';
32+
import * as openai from '@livekit/agents-plugin-openai';
33+
import * as silero from '@livekit/agents-plugin-silero';
34+
import { fileURLToPath } from 'node:url';
35+
import { z } from 'zod';
36+
37+
// ---------------------------------------------------------------------------
38+
// 1. Structured userData - a typed container for facts gathered so far
39+
// ---------------------------------------------------------------------------
40+
type ConversationData = {
41+
topic?: string;
42+
customerName?: string;
43+
customerEmail?: string;
44+
sentiment?: string;
45+
keyRequirements?: string[];
46+
prevAgent?: voice.Agent<ConversationData>;
47+
};
48+
49+
/**
50+
* Serialize collected data into a compact JSON string.
51+
* This summary is injected as a system message when the next agent starts
52+
* so it immediately has the full picture.
53+
*/
54+
function summarizeUserData(data: ConversationData): string {
55+
return JSON.stringify(
56+
{
57+
topic: data.topic ?? 'unknown',
58+
customerName: data.customerName ?? 'unknown',
59+
customerEmail: data.customerEmail ?? 'unknown',
60+
sentiment: data.sentiment ?? 'unknown',
61+
keyRequirements: data.keyRequirements ?? [],
62+
},
63+
null,
64+
2,
65+
);
66+
}
67+
68+
// ---------------------------------------------------------------------------
69+
// 2. Base agent with chat context merging on handoff
70+
// ---------------------------------------------------------------------------
71+
72+
/**
73+
* Base agent that merges the previous agent's recent chat history.
74+
*
75+
* On enter, it:
76+
* - copies a truncated view of the previous agent's chat context
77+
* (excluding system instructions and handoff markers)
78+
* - appends a system message with the serialized userData summary
79+
* - triggers an initial reply so the new agent smoothly picks up
80+
*/
81+
class BaseAgent extends voice.Agent<ConversationData> {
82+
agentName: string;
83+
84+
constructor(options: voice.AgentOptions<ConversationData> & { agentName: string }) {
85+
const { agentName, ...opts } = options;
86+
super(opts);
87+
this.agentName = agentName;
88+
}
89+
90+
async onEnter(): Promise<void> {
91+
const userData = this.session.userData;
92+
const chatCtx = this.chatCtx.copy();
93+
94+
// Merge last few turns from the previous agent so conversational
95+
// continuity is preserved without blowing up token usage.
96+
if (userData.prevAgent) {
97+
const truncatedCtx = userData.prevAgent.chatCtx
98+
.copy({
99+
excludeInstructions: true, // don't carry over old system prompt
100+
excludeFunctionCall: false, // keep tool calls for context
101+
excludeHandoff: true, // strip handoff markers
102+
})
103+
.truncate(6); // keep only the last ~3 turns (user+assistant)
104+
105+
// de-duplicate by item id to avoid repeating messages already present
106+
const existingIds = new Set(chatCtx.items.map((item) => item.id));
107+
const newItems = truncatedCtx.items.filter((item) => !existingIds.has(item.id));
108+
chatCtx.items.push(...newItems);
109+
}
110+
111+
// Inject a system message with the structured data summary so
112+
// the agent knows everything collected so far.
113+
chatCtx.addMessage({
114+
role: 'system',
115+
content: `You are the ${this.agentName} agent. Here is the current state of the conversation:\n${summarizeUserData(userData)}`,
116+
});
117+
118+
await this.updateChatCtx(chatCtx);
119+
this.session.generateReply({ toolChoice: 'none' });
120+
}
121+
}
122+
123+
// ---------------------------------------------------------------------------
124+
// 3. LLM-powered summarization before handoff + agent definitions
125+
// ---------------------------------------------------------------------------
126+
127+
class TriageAgent extends voice.Agent<ConversationData> {
128+
async onEnter() {
129+
this.session.generateReply();
130+
}
131+
132+
static create() {
133+
return new TriageAgent({
134+
instructions: [
135+
'You are a friendly triage agent. Your job is to:',
136+
'1. Greet the user and learn their name.',
137+
'2. Understand what topic they need help with.',
138+
'3. Gauge their sentiment (happy, neutral, frustrated).',
139+
'Once you have this info, call the `transferToSpecialist` tool.',
140+
].join('\n'),
141+
tools: {
142+
updateCustomerInfo: llm.tool({
143+
description: "Store the customer's name and email.",
144+
parameters: z.object({
145+
name: z.string().describe("The customer's name"),
146+
email: z.string().describe("The customer's email address"),
147+
}),
148+
execute: async ({ name, email }, { ctx }) => {
149+
ctx.userData.customerName = name;
150+
ctx.userData.customerEmail = email;
151+
return `Stored customer info: ${name} <${email}>`;
152+
},
153+
}),
154+
transferToSpecialist: llm.tool({
155+
description: 'Hand the conversation to a specialist once triage is complete.',
156+
parameters: z.object({
157+
topic: z
158+
.string()
159+
.describe('The topic the user needs help with (e.g. billing, technical, general)'),
160+
sentiment: z
161+
.string()
162+
.describe("The user's current sentiment (happy, neutral, frustrated)"),
163+
}),
164+
execute: async ({ topic, sentiment }, { ctx }) => {
165+
ctx.userData.topic = topic;
166+
ctx.userData.sentiment = sentiment;
167+
168+
// --- Strategy 3: LLM-powered summarization ---
169+
// Before handing off, compress the chat history so the specialist
170+
// gets a concise summary rather than the full transcript.
171+
// _summarize keeps the last `keepLastTurns` user/assistant pairs
172+
// verbatim and compresses everything older into a short paragraph.
173+
const currentAgent = ctx.session.currentAgent;
174+
const chatCtx = currentAgent.chatCtx.copy();
175+
const llmInstance = ctx.session.llm;
176+
if (llmInstance) {
177+
console.log('Summarizing conversation before handoff...');
178+
await chatCtx._summarize(llmInstance, { keepLastTurns: 2 });
179+
await currentAgent.updateChatCtx(chatCtx);
180+
console.log('Summarization complete.');
181+
}
182+
183+
// Store reference so the next agent's onEnter can merge our context
184+
ctx.userData.prevAgent = currentAgent;
185+
186+
const specialist = SpecialistAgent.create(topic);
187+
return llm.handoff({
188+
agent: specialist,
189+
returns: `Transferring to ${topic} specialist`,
190+
});
191+
},
192+
}),
193+
},
194+
});
195+
}
196+
}
197+
198+
class SpecialistAgent extends BaseAgent {
199+
async onEnter(): Promise<void> {
200+
// Call the base class onEnter which handles context merging + summary injection
201+
await super.onEnter();
202+
}
203+
204+
static create(topic: string) {
205+
return new SpecialistAgent({
206+
agentName: 'specialist',
207+
instructions: [
208+
`You are a specialist in ${topic}.`,
209+
'The user has already been triaged. You have their collected info',
210+
'and a summary of the prior conversation in your context.',
211+
'Help them resolve their issue. When done, call `wrapUp`.',
212+
].join(' '),
213+
tools: {
214+
recordRequirements: llm.tool({
215+
description: 'Record the specific requirements the user mentioned.',
216+
parameters: z.object({
217+
requirements: z.array(z.string()).describe('A list of specific requirements or issues'),
218+
}),
219+
execute: async ({ requirements }, { ctx }) => {
220+
ctx.userData.keyRequirements = requirements;
221+
return `Recorded ${requirements.length} requirement(s).`;
222+
},
223+
}),
224+
wrapUp: llm.tool({
225+
description: "Wrap up the conversation when the user's issue is resolved.",
226+
execute: async (_, { ctx }) => {
227+
const name = ctx.userData.customerName ?? 'there';
228+
ctx.session.interrupt();
229+
await ctx.session.generateReply({
230+
instructions: `Say goodbye to ${name} and let them know their issue is resolved.`,
231+
allowInterruptions: false,
232+
});
233+
},
234+
}),
235+
},
236+
});
237+
}
238+
}
239+
240+
// ---------------------------------------------------------------------------
241+
// Entrypoint
242+
// ---------------------------------------------------------------------------
243+
export default defineAgent({
244+
prewarm: async (proc: JobProcess) => {
245+
proc.userData.vad = await silero.VAD.load();
246+
},
247+
entry: async (ctx: JobContext) => {
248+
const userData: ConversationData = {};
249+
250+
const session = new voice.AgentSession({
251+
vad: ctx.proc.userData.vad! as silero.VAD,
252+
stt: new deepgram.STT(),
253+
llm: new openai.LLM({ model: 'gpt-4.1-mini' }),
254+
tts: new openai.TTS(),
255+
userData,
256+
turnDetection: new livekit.turnDetector.EnglishModel(),
257+
});
258+
259+
await session.start({
260+
agent: TriageAgent.create(),
261+
room: ctx.room,
262+
});
263+
},
264+
});
265+
266+
cli.runApp(new WorkerOptions({ agent: fileURLToPath(import.meta.url) }));

0 commit comments

Comments
 (0)