Skip to content

feat(agent): add LiveLlmProvider abstraction and Gemini Live WebSocket provider - #69

Open
juliantoledo wants to merge 1 commit into
android:mainfrom
juliantoledo:feat/live-llm-provider
Open

juliantoledo wants to merge 1 commit into
android:mainfrom
juliantoledo:feat/live-llm-provider

Conversation

@juliantoledo

Copy link
Copy Markdown
Contributor

Summary

Part 2 of 5 in the Gemini Live voice assistant series (following #65):

  1. PR 1: Refactor tool discovery and execution (refactor(agent): unify AppFunction discovery, input conversion, and execution #65 — merged)
  2. PR 2 (this PR): Live LLM provider layer (LiveLlmProvider abstraction + GeminiLiveProviderImpl)
  3. PR 3: Full-duplex audio capture/playback (LiveAudioSession + GeminiLiveAudioHelper)
  4. PR 4: Live session orchestration (LiveAgentOrchestrator)
  5. PR 5: Android VoiceInteractionService and Live UI integration

Changes in this PR

  • LiveLlmProvider / LiveLlmSession / LiveLlmEvent: Introduce a provider-agnostic bidirectional streaming abstraction for realtime audio, input/output transcriptions, turn completion, interruption events, and tool call requests/responses.
  • LlmProviderFactory / LlmProviderFactoryImpl: Extend the provider factory with getLiveProvider(name: LlmProviderName): LiveLlmProvider.
  • GeminiLiveProviderImpl & GeminiLiveVoice: Implement Gemini Live over the BidiGenerateContent WebSocket endpoint (models/gemini-3.8-live) using Ktor WebSockets, supporting prebuilt studio voice selection (GeminiLiveVoice persisted in SettingsRepository), realtime PCM audio streaming, input/output audio transcriptions, and tool call/response frames.
  • GeminiToolConverter & GeminiJsonUtils: Add convertForLiveApi (normalizing OpenAPI schema fields for BidiGenerateContent), selectDistinctTools, findToolByName, resolveToolName, and shared toPrimitive() / toArgumentsMap() JSON helpers used across both GeminiProviderImpl and GeminiLiveProviderImpl.
  • Unit Tests: Add unit tests in GeminiLiveProviderImplTest, GeminiToolConverterTest, and SettingsRepositoryTest.

Test Plan

  • ./gradlew spotlessCheck testStandardDebugUnitTest testRetailDebugUnitTest compileStandardDebugAndroidTestKotlin compileStandardDebugScreenshotTestKotlin

@juliantoledo
juliantoledo requested a review from a team as a code owner November 1, 2026 18:23
@juliantoledo
juliantoledo force-pushed the feat/live-llm-provider branch from d2c1af9 to e52f370 Compare November 1, 2026 21:23

@mingweiliao mingweiliao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

private val toolConverter: GeminiToolConverter,
private val settingsRepository: SettingsRepository,
) : LiveLlmProvider {
internal var webSocketConnector:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have a secondary constructor to accept this and the primary one can inject the default without relying on Hilt. Then, mark the secondary constructor with VisibleForTesting to make it clear that swapping connector is for testing purpose

tools: List<AppFunctionMetadata>,
block: suspend (LiveLlmSession) -> Unit,
) {
val selectedVoice = settingsRepository.geminiLiveVoice.first()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this to parameter and make it the caller's responsibility to specify the voice type instead

"WebSocket closed before setupComplete was received",
)
val firstJson = Json.parseToJsonElement(firstFrameText).jsonObject
if (!firstJson.containsKey("setupComplete")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract a const val. Apply to all other string that is used to parse Gemini's server response

val serverContent = serverJson["serverContent"]?.jsonObject
if (serverContent != null) {
val interrupted =
serverContent["interrupted"]?.jsonPrimitive?.booleanOrNull ?: false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic here goes through each expected key in sequence, does that mean a single serverContent might contain them together (e.g., having both interrupted and inputTranscription at the same time). If only one of them is expected, use "when" statement to branch the logic so it's clear to read

val name =
callObj["name"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
val matchingTool = toolConverter.findToolByName(name, tools)
val argumentsMap = callObj["args"]?.jsonObject.toArgumentsMap()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update this section to handle the error in a clear way. Right now there is a lot of scenario that the session will silently let it pass, which would make it difficult to debug if things go wrong (e.g., id not matching)

return null
}

internal fun parseServerMessage(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please audit the internal method in this class to see if they really need to be internal, from the test, it looks like they are not used at all

every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.e(any(), any(), any()) } returns 0

settingsRepository = mockk(relaxed = true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the FakeSettingRepository instead

import org.junit.Test
import java.util.Base64

class GeminiLiveProviderImplTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test cases in GeminiLiveProviderImplTest:

  1. Frame.Binary server frames: Please add a test verifying receiveNextJsonText() decodes Frame.Binary frames for both setupComplete and events.
  2. Unexpected first handshake frame (missing setupComplete): Please test that receiving a non-setupComplete JSON frame (e.g., {"error": ...}) throws IllegalStateException("Unexpected setup response: ...").
  3. WebSocket close() in finally: Please verify that WebSocketSession.close(CloseReason(CloseReason.Codes.NORMAL, ...)) is called when block finishes normally and when block throws/cancels.
  4. Malformed frame recovery mid-session: Please verify that if a malformed JSON frame arrives in session.events, it is skipped without crashing the flow and subsequent valid frames are still emitted.

}

@Test
fun convertForLiveApi_normalizesEnumsAndStripsFileReferenceFormat() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this to a new GeminiLiveToolConverterTest once that part is separated in implementation

Also adding these tests as well

  1. Integer enums in Live mode: Verify that AppFunctionIntTypeMetadata with enumValues omits "enums" when converted for the Live API (since OpenAPI enum in BidiGenerateContent is only valid on string types).
  2. android.net.Uri object and reference types in Live mode: Verify that "format": "file_reference" is omitted for android.net.Uri AppFunctionObjectTypeMetadata and AppFunctionReferenceTypeMetadata, not just top-level string parameters ending in Uri.
  3. selectDistinctTools, findToolByName, and resolveToolName (this should be done in ToolConvertorTest since they will no longer be Gemini specific implementation)

assertEquals(ServiceTier.PRIORITY, value)
}

@Test

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrupted/unknown stored voice fallback: Verify that if DataStore contains an unrecognized string for gemini_live_voice, repository.geminiLiveVoice.first() falls back to GeminiLiveVoice.DEFAULT (ZEPHYR).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants