diff --git a/.changeset/good-carrots-do.md b/.changeset/good-carrots-do.md new file mode 100644 index 0000000..1f32202 --- /dev/null +++ b/.changeset/good-carrots-do.md @@ -0,0 +1,5 @@ +--- +"server-sdk-kotlin": minor +--- + +Implement AgentDispatchService diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e2f98b1..4f99ec2 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -35,7 +35,7 @@ jobs: java-version: '12' distribution: 'adopt' - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.gradle/caches diff --git a/src/main/kotlin/io/livekit/server/AgentDispatchService.kt b/src/main/kotlin/io/livekit/server/AgentDispatchService.kt new file mode 100644 index 0000000..478be5a --- /dev/null +++ b/src/main/kotlin/io/livekit/server/AgentDispatchService.kt @@ -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 + + @Headers("Content-Type: application/protobuf") + @POST("/twirp/livekit.AgentDispatchService/DeleteDispatch") + fun deleteDispatch( + @Body request: LivekitAgentDispatch.DeleteAgentDispatchRequest, + @Header("Authorization") authorization: String, + ): Call + + @Headers("Content-Type: application/protobuf") + @POST("/twirp/livekit.AgentDispatchService/ListDispatch") + fun listDispatch( + @Body request: LivekitAgentDispatch.ListAgentDispatchRequest, + @Header("Authorization") authorization: String, + ): Call +} diff --git a/src/main/kotlin/io/livekit/server/AgentDispatchServiceClient.kt b/src/main/kotlin/io/livekit/server/AgentDispatchServiceClient.kt new file mode 100644 index 0000000..741907c --- /dev/null +++ b/src/main/kotlin/io/livekit/server/AgentDispatchServiceClient.kt @@ -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 { + 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 { + 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> { + 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 = 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) + } + } +} diff --git a/src/main/kotlin/io/livekit/server/okhttp/OkHttpSupplier.kt b/src/main/kotlin/io/livekit/server/okhttp/OkHttpSupplier.kt index 069d579..5443921 100644 --- a/src/main/kotlin/io/livekit/server/okhttp/OkHttpSupplier.kt +++ b/src/main/kotlin/io/livekit/server/okhttp/OkHttpSupplier.kt @@ -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 @@ -42,4 +58,4 @@ constructor( } override fun get() = okHttp -} \ No newline at end of file +} diff --git a/src/test/kotlin/io/livekit/server/AgentDispatchServiceClientTest.kt b/src/test/kotlin/io/livekit/server/AgentDispatchServiceClientTest.kt new file mode 100644 index 0000000..fd6a3d9 --- /dev/null +++ b/src/test/kotlin/io/livekit/server/AgentDispatchServiceClientTest.kt @@ -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) + } +}