Skip to content

Commit bad25fd

Browse files
fix: honor ignore on enter for toolsets (#2011)
Co-authored-by: rosetta-livekit-bot[bot] <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Co-authored-by: Toubat <brian.yin@livekit.io>
1 parent ccb51a4 commit bad25fd

7 files changed

Lines changed: 665 additions & 20 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+
Honor `ToolFlag.IGNORE_ON_ENTER` for tools nested inside `Toolset`s.

agents/src/beta/tools/end_call.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
RealtimeModel,
99
type ToolCalledEvent,
1010
type ToolCompletedEvent,
11+
ToolFlag,
1112
Toolset,
1213
tool,
1314
} from '../../llm/index.js';
@@ -68,6 +69,8 @@ export type EndCallToolOptions<UserData = UnknownUserData> = {
6869
deleteRoom?: boolean;
6970
/** Tool output to the LLM for generating the tool response. */
7071
endInstructions?: string | null;
72+
/** Hide the tool during onEnter so the model can't end the call while greeting. */
73+
ignoreOnEnter?: boolean;
7174
/** Callback to call when the tool is called. */
7275
onToolCalled?: (event: EndCallToolCalledEvent<UserData>) => Promise<void> | void;
7376
/** Callback to call when the tool is completed. */
@@ -81,6 +84,7 @@ export function createEndCallTool<UserData = UnknownUserData>({
8184
extraDescription = '',
8285
deleteRoom = true,
8386
endInstructions = 'say goodbye to the user',
87+
ignoreOnEnter = false,
8488
onToolCalled,
8589
onToolCompleted,
8690
}: EndCallToolOptions<UserData> = {}): Toolset {
@@ -116,6 +120,7 @@ export function createEndCallTool<UserData = UnknownUserData>({
116120
tool<UserData>({
117121
name: 'end_call',
118122
description: `${END_CALL_DESCRIPTION}\n${extraDescription}`,
123+
flags: ignoreOnEnter ? ToolFlag.IGNORE_ON_ENTER : ToolFlag.NONE,
119124
execute: async (_args, { ctx, abortSignal }) => {
120125
log().debug('end_call tool called');
121126
const session = ctx.session;

agents/src/llm/tool_context.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
55
import { z } from 'zod';
66
import * as z3 from 'zod/v3';
77
import * as z4 from 'zod/v4';
8+
import { createEndCallTool } from '../beta/tools/end_call.js';
89
import { voice } from '../index.js';
910
import {
1011
CONFIRM_DUPLICATE_PARAM,
@@ -15,6 +16,7 @@ import {
1516
type ToolOptions,
1617
Toolset,
1718
type ToolsetContext,
19+
isFunctionTool,
1820
tool,
1921
} from './tool_context.js';
2022
import { createToolOptions, oaiParams } from './utils.js';
@@ -701,6 +703,32 @@ describe('ToolContext', () => {
701703
expect(ctx.getFunctionTool('b')).toBe(b);
702704
});
703705

706+
it('_exclude hides a toolset member but keeps the toolset', () => {
707+
const a = makeFn('a');
708+
const b = makeFn('b');
709+
const direct = makeFn('direct');
710+
const ts = new Toolset({ id: 'set', tools: [a, b] });
711+
const ctx = new ToolContext([ts, direct]);
712+
713+
ctx._exclude([a]);
714+
715+
expect(ctx.getFunctionTool('a')).toBeUndefined();
716+
expect(ctx.flatten()).not.toContain(a);
717+
expect(ctx.getFunctionTool('b')).toBe(b);
718+
expect(ctx.getFunctionTool('direct')).toBe(direct);
719+
expect(ctx.toolsets).toEqual([ts]);
720+
});
721+
722+
it('_exclude with no tools is a no-op', () => {
723+
const a = makeFn('a');
724+
const b = makeFn('b');
725+
const ctx = new ToolContext([a, b]);
726+
727+
ctx._exclude([]);
728+
729+
expect(Object.keys(ctx.functionTools).sort()).toEqual(['a', 'b']);
730+
});
731+
704732
it('copy() yields an independent context with the same tools', () => {
705733
const a = makeFn('a');
706734
const ctx = new ToolContext([a]);
@@ -768,6 +796,22 @@ describe('ToolContext', () => {
768796
});
769797
});
770798

799+
describe('createEndCallTool', () => {
800+
it('sets IGNORE_ON_ENTER only when requested', () => {
801+
const [defaultTool] = createEndCallTool().tools;
802+
expect(defaultTool).toBeDefined();
803+
expect(isFunctionTool(defaultTool)).toBe(true);
804+
expect(isFunctionTool(defaultTool) && defaultTool.flags & ToolFlag.IGNORE_ON_ENTER).toBe(0);
805+
806+
const [ignoredTool] = createEndCallTool({ ignoreOnEnter: true }).tools;
807+
expect(ignoredTool).toBeDefined();
808+
expect(isFunctionTool(ignoredTool)).toBe(true);
809+
expect(
810+
isFunctionTool(ignoredTool) && ignoredTool.flags & ToolFlag.IGNORE_ON_ENTER,
811+
).toBeTruthy();
812+
});
813+
});
814+
771815
describe('Toolset', () => {
772816
const makeFn = (name: string) =>
773817
tool({

agents/src/llm/tool_context.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,14 +519,23 @@ export class ToolContext<UserData = UnknownUserData> {
519519
}
520520

521521
updateTools(tools: ToolContextInit<UserData>): void {
522+
this._updateTools(tools);
523+
}
524+
525+
private _updateTools(tools: ToolContextInit<UserData>, exclude: readonly Tool[] = []): void {
522526
const normalizedTools = normalizeToolContextInit(tools);
527+
const excludedTools = new Set<unknown>(exclude);
523528
this._tools = normalizedTools;
524529
this._functionToolsMap = new Map();
525530
this._providerTools = [];
526531
this._toolsets = [];
527532

528533
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any tool shape
529534
const addTool = (tool: any): void => {
535+
if (excludedTools.has(tool)) {
536+
return;
537+
}
538+
530539
if (isToolset(tool)) {
531540
for (const inner of tool.tools) {
532541
addTool(inner);
@@ -560,6 +569,42 @@ export class ToolContext<UserData = UnknownUserData> {
560569
}
561570
}
562571

572+
private _syncFlattened(tools: readonly Tool[]): void {
573+
const current = this.flatten();
574+
const currentTools = new Set(current);
575+
const nextTools = new Set(tools);
576+
if (currentTools.size === nextTools.size && current.every((tool) => nextTools.has(tool))) {
577+
return;
578+
}
579+
580+
const added: Extract<ToolContextEntry<UserData>, Tool>[] = tools
581+
.filter((tool) => !currentTools.has(tool))
582+
.filter(
583+
(tool): tool is Extract<ToolContextEntry<UserData>, Tool> =>
584+
isFunctionTool(tool) || isProviderTool(tool),
585+
);
586+
const removed = current.filter((tool) => !nextTools.has(tool));
587+
const structured = this._tools.filter((tool) => !removed.includes(tool as Tool));
588+
this._updateTools([...structured, ...added], removed);
589+
}
590+
591+
/** Hide tools from the callable set while keeping their toolsets intact. @internal */
592+
_exclude(tools: readonly Tool[]): void {
593+
if (tools.length === 0) {
594+
return;
595+
}
596+
597+
const excludedTools = new Set(tools);
598+
this._syncFlattened(this.flatten().filter((tool) => !excludedTools.has(tool)));
599+
}
600+
601+
/** Return a copy containing only flattened callable/provider entries. @internal */
602+
_flattenedCopy(): ToolContext<UserData> {
603+
const copy = ToolContext.empty<UserData>();
604+
copy._syncFlattened(this.flatten());
605+
return copy;
606+
}
607+
563608
copy(): ToolContext<UserData> {
564609
return new ToolContext<UserData>([...this._tools]);
565610
}

agents/src/voice/agent_activity.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { LLM, type LLMStream } from '../llm/llm.js';
2121
import { type Tool, ToolContext, ToolFlag, Toolset, tool } from '../llm/tool_context.js';
2222
import { Future, Task } from '../utils.js';
2323
import { _getActivityTaskInfo } from './agent.js';
24-
import { AgentActivity } from './agent_activity.js';
24+
import { AgentActivity, onEnterStorage } from './agent_activity.js';
2525
import type { PreemptiveGenerationInfo } from './audio_recognition.js';
2626
import type { AgentSessionEventTypes, UserInputTranscribedEvent } from './events.js';
2727
import { SpeechHandle } from './speech_handle.js';
@@ -582,6 +582,77 @@ describe('AgentActivity - onPreemptiveGeneration guards', () => {
582582
});
583583
});
584584

585+
describe('AgentActivity - onEnter ignored tools', () => {
586+
const makeFn = (name: string, flags = ToolFlag.NONE) =>
587+
tool({ name, description: `${name} tool`, flags, execute: async () => name });
588+
589+
function buildIgnoredToolsActivity() {
590+
const activity = Object.create(AgentActivity.prototype) as AgentActivity;
591+
Object.assign(activity, {
592+
agent: {},
593+
agentSession: {},
594+
});
595+
return activity;
596+
}
597+
598+
it('returns bare and toolset-nested IGNORE_ON_ENTER tools only inside this onEnter', () => {
599+
const endCall = makeFn('end_call', ToolFlag.IGNORE_ON_ENTER);
600+
const keep = makeFn('keep');
601+
const bareIgnored = makeFn('bare_ignored', ToolFlag.IGNORE_ON_ENTER);
602+
const bareKeep = makeFn('bare_keep');
603+
const toolset = new Toolset({ id: 'ts', tools: [endCall, keep] });
604+
const toolCtx = new ToolContext([toolset, bareIgnored, bareKeep]);
605+
const activity = buildIgnoredToolsActivity();
606+
607+
expect(activity._onEnterIgnoredTools(toolCtx)).toEqual([]);
608+
609+
onEnterStorage.run({ session: activity.agentSession, agent: activity.agent }, () => {
610+
expect(
611+
activity
612+
._onEnterIgnoredTools(toolCtx)
613+
.map((t) => t.id)
614+
.sort(),
615+
).toEqual(['bare_ignored', 'end_call']);
616+
});
617+
618+
onEnterStorage.run({ session: activity.agentSession, agent: {} as never }, () => {
619+
expect(activity._onEnterIgnoredTools(toolCtx)).toEqual([]);
620+
});
621+
});
622+
623+
it('hides ignored bare and toolset tools while preserving normal tools for nested replies', async () => {
624+
const endCall = makeFn('end_call', ToolFlag.IGNORE_ON_ENTER);
625+
const keep = makeFn('keep');
626+
const bareIgnored = makeFn('bare_ignored', ToolFlag.IGNORE_ON_ENTER);
627+
const toolset = new Toolset({ id: 'ts', tools: [endCall, keep] });
628+
const activity = buildIgnoredToolsActivity();
629+
630+
await onEnterStorage.run(
631+
{ session: activity.agentSession, agent: activity.agent },
632+
async () => {
633+
const greetingTools = new ToolContext([toolset, bareIgnored]);
634+
greetingTools._exclude(activity._onEnterIgnoredTools(greetingTools));
635+
expect(greetingTools.flatten().map((t) => t.id)).toEqual(['keep']);
636+
expect(greetingTools.toolsets).toEqual([toolset]);
637+
638+
await Promise.resolve();
639+
640+
const toolReplyTools = new ToolContext([toolset, bareIgnored]);
641+
toolReplyTools._exclude(activity._onEnterIgnoredTools(toolReplyTools));
642+
expect(toolReplyTools.flatten().map((t) => t.id)).toEqual(['keep']);
643+
},
644+
);
645+
646+
const restoredTools = new ToolContext([toolset, bareIgnored]);
647+
expect(
648+
restoredTools
649+
.flatten()
650+
.map((t) => t.id)
651+
.sort(),
652+
).toEqual(['bare_ignored', 'end_call', 'keep']);
653+
});
654+
});
655+
585656
/**
586657
* Regression test for the dynamic-toolset push path.
587658
*

agents/src/voice/agent_activity.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ import {
4141
RealtimeModel,
4242
type RealtimeModelError,
4343
type RealtimeSession,
44+
type Tool,
4445
type ToolChoice,
4546
ToolContext,
4647
type ToolContextEntry,
4748
type ToolContextLike,
4849
ToolFlag,
4950
isFunctionTool,
50-
isToolset,
5151
toToolContext,
5252
} from '../llm/index.js';
5353
import type { LLMError } from '../llm/llm.js';
@@ -2136,19 +2136,8 @@ export class AgentActivity implements RecognitionHooks {
21362136
instructions = concatInstructions(this.agent.instructions, '\n', instructions);
21372137
}
21382138

2139-
// Filter out tools with IGNORE_ON_ENTER flag when generateReply is called inside onEnter
2140-
const onEnterData = onEnterStorage.getStore();
2141-
const shouldFilterTools =
2142-
onEnterData?.agent === this.agent && onEnterData?.session === this.agentSession;
2143-
2144-
const tools: ToolContext = shouldFilterTools
2145-
? new ToolContext(
2146-
this.tools.tools.filter((t): boolean => {
2147-
if (isToolset(t) || !isFunctionTool(t)) return true;
2148-
return !(t.flags & ToolFlag.IGNORE_ON_ENTER);
2149-
}),
2150-
)
2151-
: this.tools;
2139+
const tools = this.tools;
2140+
tools._exclude(this._onEnterIgnoredTools(tools));
21522141

21532142
const task = this.createSpeechTask({
21542143
taskFn: (abortController: AbortController) =>
@@ -2234,6 +2223,19 @@ export class AgentActivity implements RecognitionHooks {
22342223
}
22352224
}
22362225

2226+
_onEnterIgnoredTools(toolCtx: ToolContext): Tool[] {
2227+
const onEnterData = onEnterStorage.getStore();
2228+
if (onEnterData?.agent !== this.agent || onEnterData.session !== this.agentSession) {
2229+
return [];
2230+
}
2231+
2232+
return toolCtx
2233+
.flatten()
2234+
.filter(
2235+
(tool): tool is Tool => isFunctionTool(tool) && !!(tool.flags & ToolFlag.IGNORE_ON_ENTER),
2236+
);
2237+
}
2238+
22372239
/**
22382240
* Commit a user turn whose reply is being skipped: append the transcript to the
22392241
* agent chat context (when non-empty) without triggering reply generation.
@@ -3910,11 +3912,21 @@ export class AgentActivity implements RecognitionHooks {
39103912
}
39113913

39123914
const originalToolChoice = this.toolChoice;
3913-
if (toolChoice !== undefined) {
3914-
this.realtimeSession.updateOptions({ toolChoice });
3915-
}
3916-
3915+
let originalTools: ToolContext | undefined;
39173916
try {
3917+
const sessionTools = this.realtimeSession.tools;
3918+
const onEnterIgnoredTools = this._onEnterIgnoredTools(sessionTools);
3919+
if (onEnterIgnoredTools.length > 0) {
3920+
originalTools = sessionTools;
3921+
const turnTools = sessionTools._flattenedCopy();
3922+
turnTools._exclude(onEnterIgnoredTools);
3923+
await this.realtimeSession.updateTools(turnTools);
3924+
}
3925+
3926+
if (toolChoice !== undefined) {
3927+
this.realtimeSession.updateOptions({ toolChoice });
3928+
}
3929+
39183930
const generateReplyAbortController = new AbortController();
39193931
const generationPromise = this.realtimeSession.generateReply(
39203932
instructions !== undefined
@@ -3940,7 +3952,18 @@ export class AgentActivity implements RecognitionHooks {
39403952
} finally {
39413953
// reset toolChoice value
39423954
if (toolChoice !== undefined && toolChoice !== originalToolChoice) {
3943-
this.realtimeSession.updateOptions({ toolChoice: originalToolChoice });
3955+
try {
3956+
this.realtimeSession.updateOptions({ toolChoice: originalToolChoice });
3957+
} catch (error) {
3958+
this.logger.error({ error }, 'failed to reset tool_choice');
3959+
}
3960+
}
3961+
if (originalTools !== undefined) {
3962+
try {
3963+
await this.realtimeSession.updateTools(originalTools);
3964+
} catch (error) {
3965+
this.logger.error({ error }, 'failed to reset tools');
3966+
}
39443967
}
39453968
}
39463969
}

0 commit comments

Comments
 (0)