-
Notifications
You must be signed in to change notification settings - Fork 346
Add support for tools to Phonic Plugin #1076
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
9240f5a
Add llm tool support
qionghuang6 f499847
Merge branch 'main' into qiong/phonic-tools
qionghuang6 10948a6
tools cleanup
qionghuang6 8929789
Add changeset
qionghuang6 54995d2
reduce sleep time
qionghuang6 f657c15
Change to use futures
qionghuang6 309e355
Fix pnpm lock
qionghuang6 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@livekit/agents-plugin-phonic': patch | ||
| --- | ||
|
|
||
| Add support for LiveKit tools to Phonic plugin |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import type { APIConnectOptions } from '@livekit/agents'; | |
| import { | ||
| AudioByteStream, | ||
| DEFAULT_API_CONNECT_OPTIONS, | ||
| Future, | ||
| llm, | ||
| log, | ||
| shortuuid, | ||
|
|
@@ -21,6 +22,7 @@ const PHONIC_NUM_CHANNELS = 1; | |
| const PHONIC_INPUT_FRAME_MS = 20; | ||
| const DEFAULT_MODEL = 'merritt'; | ||
| const WS_CLOSE_NORMAL = 1000; | ||
| const TOOL_CALL_OUTPUT_TIMEOUT_MS = 60_000; | ||
|
|
||
| export interface RealtimeModelOptions { | ||
| apiKey: string; | ||
|
|
@@ -125,8 +127,6 @@ export class RealtimeModel extends llm.RealtimeModel { | |
| messageTruncation: false, | ||
| turnDetection: true, | ||
| userTranscription: true, | ||
| // TODO @Phonic-Co: Implement tool support | ||
| // Phonic has automatic tool reply generation, but tools are not supported with LiveKit Agents yet. | ||
| autoToolReplyGeneration: true, | ||
| manualFunctionCalls: false, | ||
| audioOutput: true, | ||
|
|
@@ -197,19 +197,17 @@ export class RealtimeSession extends llm.RealtimeSession { | |
| private logger = log(); | ||
| private closed = false; | ||
| private configSent = false; | ||
| private instructionsReady: Promise<void>; | ||
| private resolveInstructionsReady: () => void; | ||
| private instructionsReady = new Future<void>(); | ||
| private toolsReady = new Future<void>(); | ||
| private connectTask: Promise<void>; | ||
| private toolDefinitions: Record<string, unknown>[] = []; | ||
| private pendingToolCallIds = new Set<string>(); | ||
| private readyToStart = false; | ||
|
|
||
| constructor(realtimeModel: RealtimeModel) { | ||
| super(realtimeModel); | ||
| this.options = realtimeModel._options; | ||
|
|
||
| this.resolveInstructionsReady = () => {}; | ||
| this.instructionsReady = new Promise<void>((resolve) => { | ||
| this.resolveInstructionsReady = resolve; | ||
| }); | ||
|
|
||
| this.client = new PhonicClient({ | ||
| apiKey: this.options.apiKey, | ||
| baseUrl: this.options.baseUrl, | ||
|
|
@@ -241,25 +239,70 @@ export class RealtimeSession extends llm.RealtimeSession { | |
| return; | ||
| } | ||
| this.options.instructions = instructions; | ||
| this.resolveInstructionsReady(); | ||
| this.instructionsReady.resolve(); | ||
| } | ||
|
|
||
| async updateChatCtx(_chatCtx: llm.ChatContext): Promise<void> { | ||
| this.logger.warn('updateChatCtx is not supported by the Phonic realtime model.'); | ||
| async updateChatCtx(chatCtx: llm.ChatContext): Promise<void> { | ||
| let sent = false; | ||
| for (const item of chatCtx.items) { | ||
| if (item.type === 'function_call_output' && this.pendingToolCallIds.has(item.callId)) { | ||
| this.pendingToolCallIds.delete(item.callId); | ||
| this.logger.info(`Sending tool call output for ${item.name} (call_id: ${item.callId})`); | ||
| this.socket?.sendToolCallOutput({ | ||
| type: 'tool_call_output', | ||
| tool_call_id: item.callId, | ||
| output: item.output, | ||
| }); | ||
| sent = true; | ||
| } | ||
| } | ||
| if (!sent) { | ||
| this.logger.warn( | ||
| 'updateChatCtx called but no new tool call outputs to send. Phonic does not support general chat context updates.', | ||
| ); | ||
| } else { | ||
| this.startNewAssistantTurn(); | ||
| } | ||
|
qionghuang6 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| async updateTools(tools: llm.ToolContext): Promise<void> { | ||
| if (Object.keys(tools).length > 0) { | ||
| this.logger.warn('Tool use is not supported by the Phonic realtime model.'); | ||
| if (this.configSent) { | ||
| this.logger.warn( | ||
| 'updateTools called after config was already sent. Phonic does not support updating tools mid-session.', | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| this._tools = { ...tools }; | ||
| this.toolDefinitions = Object.entries(tools) | ||
| .filter(([_, tool]) => llm.isFunctionTool(tool)) | ||
| .map(([name, tool]) => ({ | ||
| type: 'custom_websocket', | ||
| tool_schema: { | ||
| type: 'function', | ||
| function: { | ||
| name, | ||
| description: tool.description, | ||
| parameters: llm.toJsonSchema(tool.parameters), | ||
| strict: true, | ||
| }, | ||
| }, | ||
| tool_call_output_timeout_ms: TOOL_CALL_OUTPUT_TIMEOUT_MS, | ||
| // Tool chaining and tool calls during speech are not supported at this time | ||
| // for ease of implementation within the RealtimeSession generations framework | ||
|
Comment on lines
+291
to
+292
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| wait_for_speech_before_tool_call: true, | ||
| allow_tool_chaining: false, | ||
| })); | ||
|
|
||
| this.toolsReady.resolve(); | ||
| } | ||
|
|
||
| updateOptions(_options: { toolChoice?: llm.ToolChoice | null }): void { | ||
| this.logger.warn('updateOptions is not supported by the Phonic realtime model.'); | ||
| } | ||
|
|
||
| pushAudio(frame: AudioFrame): void { | ||
| if (this.closed) { | ||
| if (this.closed || !this.readyToStart) { | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -294,7 +337,9 @@ export class RealtimeSession extends llm.RealtimeSession { | |
| } | ||
|
|
||
| async interrupt(): Promise<void> { | ||
| this.logger.warn('interrupt is not supported by the Phonic realtime model.'); | ||
| this.logger.warn( | ||
| 'interrupt() is not supported by Phonic realtime model. User interruptions are automatically handled by Phonic.', | ||
| ); | ||
| } | ||
|
|
||
| async truncate(_options: { messageId: string; audioEndMs: number; audioTranscript?: string }) { | ||
|
|
@@ -303,7 +348,8 @@ export class RealtimeSession extends llm.RealtimeSession { | |
|
|
||
| async close(): Promise<void> { | ||
| this.closed = true; | ||
| this.resolveInstructionsReady(); | ||
| this.instructionsReady.resolve(); | ||
| this.toolsReady.resolve(); | ||
| this.closeCurrentGeneration({ interrupted: false }); | ||
| this.socket?.close(); | ||
| await this.connectTask; | ||
|
|
@@ -332,8 +378,10 @@ export class RealtimeSession extends llm.RealtimeSession { | |
| }); | ||
|
|
||
| await this.socket.waitForOpen(); | ||
| await this.instructionsReady; | ||
| await this.instructionsReady.await; | ||
| await this.toolsReady.await; | ||
| if (this.closed) return; | ||
|
|
||
| this.configSent = true; | ||
| this.socket.sendConfig({ | ||
| type: 'config', | ||
|
|
@@ -348,7 +396,7 @@ export class RealtimeSession extends llm.RealtimeSession { | |
| output_format: 'pcm_44100', | ||
| recognized_languages: this.options.languages, | ||
| audio_speed: this.options.audioSpeed, | ||
| tools: this.options.phonicTools, | ||
| tools: [...(this.options.phonicTools ?? []), ...this.toolDefinitions], | ||
| boosted_keywords: this.options.boostedKeywords, | ||
| generate_no_input_poke_text: this.options.generateNoInputPokeText, | ||
| no_input_poke_sec: this.options.noInputPokeSec, | ||
|
|
@@ -381,17 +429,12 @@ export class RealtimeSession extends llm.RealtimeSession { | |
| case 'user_finished_speaking': | ||
| this.handleInputSpeechStopped(); | ||
| break; | ||
| case 'tool_call': | ||
| this.handleToolCall(message); | ||
| break; | ||
| case 'error': | ||
| this.emitError(new Error(message.error.message), false); | ||
| break; | ||
| case 'tool_call': | ||
| this.emitError( | ||
| new Error( | ||
| `WebSocket tool calls are not yet supported by the Phonic realtime model with LiveKit Agents.`, | ||
| ), | ||
| false, | ||
| ); | ||
| break; | ||
| case 'assistant_ended_conversation': | ||
| this.emitError( | ||
| new Error( | ||
|
|
@@ -404,11 +447,15 @@ export class RealtimeSession extends llm.RealtimeSession { | |
| this.conversationId = message.conversation_id; | ||
| this.logger.info(`Phonic Conversation began with ID: ${this.conversationId}`); | ||
| break; | ||
| case 'assistant_chose_not_to_respond': | ||
| case 'tool_call_interrupted': | ||
| this.handleToolCallInterrupted(message); | ||
| break; | ||
| case 'ready_to_start_conversation': | ||
| this.readyToStart = true; | ||
| break; | ||
| case 'assistant_chose_not_to_respond': | ||
| case 'input_cancelled': | ||
| case 'tool_call_output_processed': | ||
| case 'tool_call_interrupted': | ||
| case 'dtmf': | ||
| default: | ||
| break; | ||
|
|
@@ -420,8 +467,13 @@ export class RealtimeSession extends llm.RealtimeSession { | |
| * Although Phonic sends audio chunks when the assistant is not speaking (i.e. containing silence or background noise), | ||
| * we only process the chunks when the assistant is speaking to align with the generations model, whereby new streams are created for each turn. | ||
| */ | ||
| if (this.currentGeneration === undefined && message.text) { | ||
| this.logger.debug('Starting new generation due to text in audio chunk'); | ||
| this.startNewAssistantTurn(); | ||
| } | ||
|
|
||
| const gen = this.currentGeneration; | ||
| if (!gen) return; | ||
| if (gen === undefined) return; | ||
|
|
||
| if (message.text) { | ||
| gen.outputText += message.text; | ||
|
|
@@ -464,6 +516,32 @@ export class RealtimeSession extends llm.RealtimeSession { | |
| }); | ||
| } | ||
|
|
||
| private handleToolCall(message: Phonic.ToolCallPayload): void { | ||
| this.pendingToolCallIds.add(message.tool_call_id); | ||
|
|
||
| if (this.currentGeneration === undefined) { | ||
| this.logger.warn('Encountered tool call but no active generation. Starting new turn.'); | ||
| this.startNewAssistantTurn(); | ||
| } | ||
|
|
||
| this.currentGeneration!.functionChannel.write( | ||
| llm.FunctionCall.create({ | ||
| callId: message.tool_call_id, | ||
| name: message.tool_name, | ||
| args: JSON.stringify(message.parameters), | ||
| }), | ||
| ); | ||
| // At most 1 tool call is supported per turn due to `toolChaining: false`, allowing us to close the generation | ||
| this.closeCurrentGeneration({ interrupted: false }); | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
|
|
||
| private handleToolCallInterrupted(message: Phonic.ToolCallInterruptedPayload): void { | ||
| this.pendingToolCallIds.delete(message.tool_call_id); | ||
| this.logger.warn( | ||
| `Tool call for ${message.tool_name} (call_id: ${message.tool_call_id}) was cancelled due to user interruption.`, | ||
| ); | ||
| } | ||
|
|
||
| private handleInputSpeechStarted(): void { | ||
| this.emit('input_speech_started', {}); | ||
| this.closeCurrentGeneration({ interrupted: true }); | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.