Skip to content
Open
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/disallowed-tool-replies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Preserve completed realtime tool outputs when a generation is interrupted, and prevent disallowed tool calls from starting unbounded reply generations.
20 changes: 20 additions & 0 deletions agents/src/voice/agent_activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3878,6 +3878,26 @@ export class AgentActivity implements RecognitionHooks {
}
speechHandle._markGenerationDone();
await this.cancelToolExecutions(executeToolsTask, speechHandle, toolOutput);
const interruptedToolOutputs = toolOutput.output
.filter((output) => output.agentTask === undefined)
.map((output) => output.toolCallOutput)
.filter((output): output is FunctionCallOutput => output !== undefined);
this._commitInterruptedToolOutputs(toolOutput, speechHandle, Date.now());
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
if (interruptedToolOutputs.length > 0) {
try {
// Tool responses still need to reach the provider when arbitrary mid-session chat
// updates are unsupported. Realtime implementations use updateChatCtx as the
// transport for function-call outputs so providers can clear pending call IDs.
const chatCtx = realtimeSession.chatCtx.copy();
chatCtx.items.push(...interruptedToolOutputs);
await realtimeSession.updateChatCtx(chatCtx);
} catch (error) {
this.logger.warn(
{ error, speech_id: speechHandle.id },
'failed to flush tool outputs after realtime generation was interrupted',
);
}
}

// TODO(brian): close tees
return;
Expand Down
24 changes: 21 additions & 3 deletions agents/src/voice/generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,9 @@ export function createToolOutput(params: {
toolCall: FunctionCall;
output?: unknown;
exception?: Error;
replyRequired?: boolean;
}): ToolExecutionOutput {
const { toolCall, output, exception } = params;
const { toolCall, output, exception, replyRequired } = params;
const logger = log();

// support returning Exception instead of raising them (for devex purposes inside evals)
Expand All @@ -395,6 +396,7 @@ export function createToolOutput(params: {
}),
rawOutput: finalOutput,
rawException: finalException,
replyRequired,
});
}

Expand All @@ -403,6 +405,7 @@ export function createToolOutput(params: {
toolCall: FunctionCall.create({ ...toolCall }),
rawOutput: finalOutput,
rawException: finalException,
replyRequired,
});
}

Expand All @@ -417,6 +420,7 @@ export function createToolOutput(params: {
}),
rawOutput: finalOutput,
rawException: finalException,
replyRequired,
});
}

Expand All @@ -439,6 +443,7 @@ export function createToolOutput(params: {
toolCall: FunctionCall.create({ ...toolCall }),
rawOutput: finalOutput,
rawException: finalException,
replyRequired,
});
}

Expand All @@ -450,7 +455,7 @@ export function createToolOutput(params: {
output: toolOutput !== undefined ? JSON.stringify(toolOutput) : '', // take the string representation of the output
isError: false,
}),
replyRequired: toolOutput !== undefined, // require a reply if the tool returned an output
replyRequired: replyRequired ?? toolOutput !== undefined, // require a reply if the tool returned an output
agentTask,
rawOutput: finalOutput,
rawException: finalException,
Expand Down Expand Up @@ -1151,12 +1156,25 @@ export function performToolExecutions({
if (done) break;

if (toolChoice === 'none') {
const message =
`Tool calls are not allowed on this turn because toolChoice is set to 'none'. ` +
`${toolCall.name} was not executed.`;
logger.error(
{
function: toolCall.name,
speech_id: speechHandle.id,
},
"received a tool call with toolChoice set to 'none', ignoring",
"received a tool call with toolChoice set to 'none', rejecting",
);
// Record the consumed call so its error output has a matching history entry, even though
// the tool itself is intentionally not executed.
onToolExecutionStarted(toolCall);
toolCompleted(
createToolOutput({
toolCall,
exception: new ToolError(message),
replyRequired: false,
}),
);
continue;
}
Expand Down
46 changes: 46 additions & 0 deletions agents/src/voice/generation_tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,52 @@ describe('Generation + Tool Execution', () => {
expect(out?.toolCallOutput?.output).toContain('echo: hello');
});

it("returns a non-replying error response when a model calls a tool with toolChoice set to 'none'", async () => {
const execute = vi.fn(async () => 'should not run');
const forbidden = tool({
name: 'forbidden',
description: 'must not execute on this turn',
parameters: z.object({}),
execute,
});
const fc = FunctionCall.create({
callId: 'call_forbidden',
name: forbidden.name,
args: '{}',
});
const onToolExecutionStarted = vi.fn();
const onToolExecutionCompleted = vi.fn();

const [execTask, toolOutput] = performToolExecutions({
session: {} as AgentSession,
speechHandle: { id: 'speech_forbidden', _itemAdded: () => {} } as unknown as SpeechHandle,
toolCtx: new ToolContext([forbidden]) as unknown as ToolContext,
toolChoice: 'none',
toolCallStream: createFunctionCallStream(fc),
controller: new AbortController(),
onToolExecutionStarted,
onToolExecutionCompleted,
});

await execTask.result;

expect(execute).not.toHaveBeenCalled();
expect(onToolExecutionStarted).toHaveBeenCalledOnce();
expect(onToolExecutionStarted).toHaveBeenCalledWith(fc);
expect(toolOutput.output).toHaveLength(1);
expect(onToolExecutionCompleted).toHaveBeenCalledWith(toolOutput.output[0]);
expect(toolOutput.output[0]?.toolCall.callId).toBe(fc.callId);
expect(toolOutput.output[0]?.toolCallOutput).toEqual(
expect.objectContaining({
callId: fc.callId,
name: fc.name,
isError: true,
}),
);
expect(toolOutput.output[0]?.toolCallOutput?.output).toContain("toolChoice is set to 'none'");
expect(toolOutput.output[0]?.replyRequired).toBe(false);
});

it('should repair and canonicalize leaked template tokens in tool args', async () => {
const replyAbortController = new AbortController();

Expand Down
67 changes: 64 additions & 3 deletions agents/src/voice/realtime_tool_output_commit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ class FakeRealtimeSession extends RealtimeSession {
private _chatCtx = ChatContext.empty();
private _tools = ToolContext.empty();

constructor(
model: RealtimeModel,
private readonly keepTextStreamOpen: boolean = false,
) {
super(model);
}

get chatCtx(): ChatContext {
return this._chatCtx;
}
Expand All @@ -55,10 +62,18 @@ class FakeRealtimeSession extends RealtimeSession {
async truncate(): Promise<void> {}

async generateReply(): Promise<GenerationCreatedEvent> {
const textStream = this.keepTextStreamOpen
? new ReadableStream<string>({
start(controller) {
controller.enqueue('Let me check that.');
},
})
: stream('Let me check that.');

return {
messageStream: stream({
messageId: 'message-1',
textStream: stream('Let me check that.'),
textStream,
audioStream: stream<AudioFrame>(),
modalities: Promise.resolve(['text']),
}),
Expand All @@ -72,9 +87,9 @@ class FakeRealtimeSession extends RealtimeSession {
}

class FakeRealtimeModel extends RealtimeModel {
readonly activeSession = new FakeRealtimeSession(this);
readonly activeSession: FakeRealtimeSession;

constructor() {
constructor(options: { keepTextStreamOpen?: boolean } = {}) {
// autoToolReplyGeneration keeps the run to a single generation.
super({
messageTruncation: false,
Expand All @@ -85,6 +100,7 @@ class FakeRealtimeModel extends RealtimeModel {
manualFunctionCalls: false,
midSessionChatCtxUpdate: true,
} satisfies RealtimeCapabilities);
this.activeSession = new FakeRealtimeSession(this, options.keepTextStreamOpen);
}

get model(): string {
Expand Down Expand Up @@ -134,4 +150,49 @@ describe('Realtime tool output commit', () => {
// The output must follow its call so summarization renders them in order.
expect(items.indexOf(output!)).toBeGreaterThan(items.indexOf(call!));
});

it('commits completed tool outputs when realtime generation is interrupted', async () => {
const llm = new FakeRealtimeModel({ keepTextStreamOpen: true });
const session = new AgentSession({
llm,
vad: null,
turnHandling: { turnDetection: null },
});
const agent = new Agent({
instructions: 'test',
tools: { lookup_order: tool({ description: 'x', execute: async () => 'ships tomorrow' }) },
});

await session.start({ agent });
try {
const speech = session.generateReply();

await vi.waitFor(() =>
expect(
speech.chatItems.some(
(item) => item.type === 'function_call_output' && item.callId === TOOL_CALL_ID,
),
).toBe(true),
);

speech.interrupt();
await speech.waitForPlayout();

await vi.waitFor(() =>
expect(
llm.activeSession.chatCtx.items.some(
(item) => item.type === 'function_call_output' && item.callId === TOOL_CALL_ID,
),
).toBe(true),
);
} finally {
await session.close();
}

expect(
agent.chatCtx.items.some(
(item) => item.type === 'function_call_output' && item.callId === TOOL_CALL_ID,
),
).toBe(true);
});
});