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/good-carrots-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"server-sdk-kotlin": minor
---

Implement AgentDispatchService
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
java-version: '12'
distribution: 'adopt'

- uses: actions/cache@v2
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
Expand Down
50 changes: 50 additions & 0 deletions src/main/kotlin/io/livekit/server/AgentDispatchService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2025 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.livekit.server

import livekit.LivekitAgentDispatch
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.POST

/**
* Retrofit Interface for accessing the AgentDispatchService Apis.
*/
interface AgentDispatchService {
@Headers("Content-Type: application/protobuf")
@POST("/twirp/livekit.AgentDispatchService/CreateDispatch")
fun createDispatch(
@Body request: LivekitAgentDispatch.CreateAgentDispatchRequest,
@Header("Authorization") authorization: String,
): Call<LivekitAgentDispatch.AgentDispatch>

@Headers("Content-Type: application/protobuf")
@POST("/twirp/livekit.AgentDispatchService/DeleteDispatch")
fun deleteDispatch(
@Body request: LivekitAgentDispatch.DeleteAgentDispatchRequest,
@Header("Authorization") authorization: String,
): Call<LivekitAgentDispatch.AgentDispatch>

@Headers("Content-Type: application/protobuf")
@POST("/twirp/livekit.AgentDispatchService/ListDispatch")
fun listDispatch(
@Body request: LivekitAgentDispatch.ListAgentDispatchRequest,
@Header("Authorization") authorization: String,
): Call<LivekitAgentDispatch.ListAgentDispatchResponse>
}
135 changes: 135 additions & 0 deletions src/main/kotlin/io/livekit/server/AgentDispatchServiceClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright 2025 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.livekit.server

import io.livekit.server.okhttp.OkHttpFactory
import io.livekit.server.okhttp.OkHttpHolder
import io.livekit.server.retrofit.TransformCall
import livekit.LivekitAgentDispatch
import okhttp3.OkHttpClient
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.protobuf.ProtoConverterFactory
import java.util.function.Supplier

/**
* A client for explicit agent dispatch.
*
* See: [Dispatching agents](https://docs.livekit.io/agents/build/dispatch/#explicit-agent-dispatch)
*/
class AgentDispatchServiceClient(
private val service: AgentDispatchService,
private val apiKey: String,
private val secret: String,
) {

/**
* Creates an agent dispatch in a room.
* @param room Name of the room to create dispatch in
* @param agentName Name of the agent to dispatch
* @param metadata Optional metadata to attach to the dispatch
* @return Created agent dispatch
*/
@JvmOverloads
fun createDispatch(
room: String,
agentName: String,
metadata: String? = null,
): Call<LivekitAgentDispatch.AgentDispatch> {
val request = with(LivekitAgentDispatch.CreateAgentDispatchRequest.newBuilder()) {
setRoom(room)
setAgentName(agentName)
if (metadata != null) {
setMetadata(metadata)
}
build()
}
val credentials = authHeader(RoomAdmin(true), RoomName(room))
return service.createDispatch(request, credentials)
}

/**
* Deletes an agent dispatch from a room.
* @param room Name of the room to delete dispatch from
* @param dispatchId ID of the dispatch to delete
* @return Deleted agent dispatch
*/
fun deleteDispatch(room: String, dispatchId: String): Call<LivekitAgentDispatch.AgentDispatch> {
val request = LivekitAgentDispatch.DeleteAgentDispatchRequest.newBuilder()
.setRoom(room)
.setDispatchId(dispatchId)
.build()
val credentials = authHeader(RoomAdmin(true), RoomName(room))
return service.deleteDispatch(request, credentials)
}

/**
* List all agent dispatches in a room.
* @param room Name of the room to list dispatches from
* @return List of agent dispatches
*/
fun listDispatch(room: String): Call<List<LivekitAgentDispatch.AgentDispatch>> {
val request = LivekitAgentDispatch.ListAgentDispatchRequest.newBuilder()
.setRoom(room)
.build()
val credentials = authHeader(RoomAdmin(true), RoomName(room))
return TransformCall(service.listDispatch(request, credentials)) {
it.agentDispatchesList
}
}

private fun authHeader(vararg videoGrants: VideoGrant): String {
val accessToken = AccessToken(apiKey, secret)
accessToken.addGrants(*videoGrants)

val jwt = accessToken.toJwt()

return "Bearer $jwt"
}

companion object {

/**
* Create a new [AgentDispatchServiceClient] with the given host, api key, and secret.
*
* @param okHttpSupplier provide an [OkHttpFactory] if you wish to customize the http client
* (e.g. proxy, timeout, certificate/auth settings), or supply your own OkHttpClient
* altogether to pool resources with [OkHttpHolder].
*
* @see OkHttpHolder
* @see OkHttpFactory
*/
@JvmStatic
@JvmOverloads
fun createClient(
host: String,
apiKey: String,
secret: String,
okHttpSupplier: Supplier<OkHttpClient> = OkHttpFactory(),
): AgentDispatchServiceClient {
val okhttp = okHttpSupplier.get()
val service = Retrofit.Builder()
.baseUrl(host)
.addConverterFactory(ProtoConverterFactory.create())
.client(okhttp)
.build()
.create(AgentDispatchService::class.java)

return AgentDispatchServiceClient(service, apiKey, secret)
}
}
}
18 changes: 17 additions & 1 deletion src/main/kotlin/io/livekit/server/okhttp/OkHttpSupplier.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
/*
* Copyright 2025 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.livekit.server.okhttp

import okhttp3.OkHttpClient
Expand Down Expand Up @@ -42,4 +58,4 @@ constructor(
}

override fun get() = okHttp
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright 2025 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.livekit.server

import io.livekit.server.okhttp.OkHttpFactory
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue

class AgentDispatchServiceClientTest {

companion object {
const val HOST = TestConstants.HOST
const val KEY = TestConstants.KEY
const val SECRET = TestConstants.SECRET

const val ROOM_NAME = "room_name"
const val METADATA = "metadata"
}

lateinit var client: AgentDispatchServiceClient
lateinit var roomClient: RoomServiceClient

@BeforeTest
fun setup() {
client = AgentDispatchServiceClient.createClient(HOST, KEY, SECRET, OkHttpFactory(true, null))
roomClient = RoomServiceClient.createClient(HOST, KEY, SECRET, OkHttpFactory(true, null))
}

@Test
fun createAgentDispatch() {
roomClient.createRoom(name = ROOM_NAME).execute()
client.createDispatch(
room = ROOM_NAME,
agentName = "agent",
).execute()
}

@Test
fun listAgentDispatch() {
roomClient.createRoom(name = ROOM_NAME).execute()
val dispatchResp = client.createDispatch(
room = ROOM_NAME,
agentName = "agent",
metadata = METADATA,
).execute()
val dispatch = dispatchResp.body()

assertNotNull(dispatch?.id)

val listResp = client.listDispatch(room = ROOM_NAME).execute()
val allDispatches = listResp.body()
assertTrue(listResp.isSuccessful)
assertNotNull(allDispatches)
assertTrue(allDispatches.any { item -> item.id == dispatch?.id })
}

@Test
fun deleteAgentDispatch() {
roomClient.createRoom(name = ROOM_NAME).execute()
val dispatchResp = client.createDispatch(
room = ROOM_NAME,
agentName = "agent",
metadata = METADATA,
).execute()
val dispatch = dispatchResp.body()

assertNotNull(dispatch?.id)

val deleteResp = client.deleteDispatch(room = ROOM_NAME, dispatchId = dispatch?.id ?: "").execute()
assertTrue(deleteResp.isSuccessful)
}
}