Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tolerate-list-deltas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Handle list-shaped content parts in streamed LLM deltas when stripping thinking tokens.
70 changes: 70 additions & 0 deletions agents/src/llm/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,76 @@ describe('stripThinkingTokens', () => {
collectVisibleText(['before<|channel>thought\nprivate reasoning'], GEMMA_THINK_TAGS),
).toBe('before');
});

it('flattens list-shaped delta content', () => {
const state = new ThinkingTokenFilter();
expect(stripThinkingTokens([{ type: 'text', text: 'Hallo' }], state)).toBe('Hallo');
});

it('flattens multiple and string parts', () => {
const state = new ThinkingTokenFilter();
expect(
stripThinkingTokens(
[{ type: 'text', text: 'Hello ' }, 'from ', { type: 'text', text: 'LiveKit' }],
state,
),
).toBe('Hello from LiveKit');
});

it('strips thinking tokens inside list parts', () => {
const state = new ThinkingTokenFilter();
const visible: string[] = [];
for (const chunk of [
[{ type: 'text', text: '<think>private ' }],
[{ type: 'text', text: 'reasoning</think>answer' }],
]) {
const content = stripThinkingTokens(chunk, state);
if (content !== undefined) {
visible.push(content);
}
}

const content = stripThinkingTokens(undefined, state, { final: true });
if (content !== undefined) {
visible.push(content);
}
expect(visible.join('')).toBe('answer');
});

it('ignores list parts without text', () => {
const state = new ThinkingTokenFilter();
expect(
stripThinkingTokens(
[
{ type: 'image_url', image_url: { url: 'https://x' } },
{ type: 'text', text: 'hi' },
],
state,
),
).toBe('hi');
});

it('treats a list without any text part as no content', () => {
const state = new ThinkingTokenFilter();
expect(stripThinkingTokens([], state)).toBeUndefined();
expect(
stripThinkingTokens([{ type: 'image_url', image_url: { url: 'https://x' } }], state),
).toBeUndefined();
});

it('keeps an empty text part as empty content', () => {
const state = new ThinkingTokenFilter();
expect(stripThinkingTokens([{ type: 'text', text: '' }], state)).toBe('');
});

it('flattens object parts with a text property', () => {
class Part {
text = 'typed part';
}

const state = new ThinkingTokenFilter();
expect(stripThinkingTokens([new Part()], state)).toBe('typed part');
});
});

describe('executeToolCall', () => {
Expand Down
35 changes: 34 additions & 1 deletion agents/src/llm/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { VideoBufferType, VideoFrame } from '@livekit/rtc-node';
import type { JSONSchema7 } from 'json-schema';
import { jsonrepair } from 'jsonrepair';
import sharp from 'sharp';
import { log } from '../log.js';
import type { UnknownUserData } from '../voice/run_context.js';
import type { ChatContext } from './chat_context.js';
import {
Expand Down Expand Up @@ -51,11 +52,43 @@ function partialMarkerLength(content: string, markers: string[]): number {
return longest;
}

function flattenDeltaContent(content: string | unknown[] | null | undefined) {
if (content === null || content === undefined || typeof content === 'string') {
return content;
}

if (Array.isArray(content)) {
const parts: string[] = [];
for (const part of content) {
if (typeof part === 'string') {
parts.push(part);
continue;
}

const text =
typeof part === 'object' && part !== null && 'text' in part ? part.text : undefined;
if (typeof text === 'string') {
parts.push(text);
}
}

// No text part carries no content, unlike a part containing an empty string.
return parts.length > 0 ? parts.join('') : undefined;
}

log().warn(
{ contentType: typeof content },
'unexpected streaming delta content type; dropping the chunk',
);
return undefined;
}

export function stripThinkingTokens(
content: string | null | undefined,
content: string | unknown[] | null | undefined,
state: ThinkingTokenFilter,
{ final = false }: { final?: boolean } = {},
): string | undefined {
content = flattenDeltaContent(content);
if (content !== null && content !== undefined) {
state.buffer += content;
}
Expand Down
Loading