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
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,63 @@ val config = ConversationConfig(
)
```

When the agent issues a `client_tool_call`, the SDK executes the matching tool and responds with a `client_tool_result`. If the tool is not registered, `onUnhandledClientToolCall` is invoked and a failure result is returned to the agent (if a response is expected).
When the agent issues a `client_tool_call`, the SDK executes the matching tool and responds with a `client_tool_result`. If the tool is not registered:
- If `onUnhandledClientToolCall` callback is provided, it will be invoked and you must handle the response manually using `sendToolResult()`
- If no callback is provided and the tool expects a response, an automatic failure will be sent to prevent the agent from hanging

### Dynamic Client Tools

For runtime-defined tools or tools that can't be registered upfront, you can handle them dynamically using the `onUnhandledClientToolCall` callback combined with `sendToolResult()`:

```kotlin
val config = ConversationConfig(
agentId = "<public_agent>",
onUnhandledClientToolCall = { toolCall ->
// Handle dynamic tool execution
when (toolCall.toolName) {
"getDeviceInfo" -> {
val result = mapOf(
"success" to true,
"result" to "Device: ${Build.MODEL}",
"error" to ""
)
session.sendToolResult(toolCall.toolCallId, result, isError = false)
}
"fetchUserData" -> {
// Perform async operation
coroutineScope.launch {
val data = fetchDataFromAPI(toolCall.parameters)
val result = mapOf(
"success" to true,
"result" to data,
"error" to ""
)
session.sendToolResult(toolCall.toolCallId, result, isError = false)
}
}
else -> {
// Unknown tool
val errorResult = mapOf(
"success" to false,
"result" to "",
"error" to "Unknown tool: ${toolCall.toolName}"
)
session.sendToolResult(toolCall.toolCallId, errorResult, isError = true)
}
}
}
)
```

**Key methods:**
- `session.sendToolResult(toolCallId, result, isError)`: Send tool execution results back to the agent manually. Use this in the `onUnhandledClientToolCall` callback to respond to dynamic tool calls.
- `toolCall.expectsResponse`: Check this property to determine if the agent expects a response. If `false`, the tool is fire-and-forget and you can skip calling `sendToolResult()`.

This approach is useful for:
- Tools that are determined at runtime based on user settings
- Tools that require complex async operations
- Integration with external APIs or databases
- Scenarios where tool availability depends on app state or permissions

---

Expand Down
6 changes: 5 additions & 1 deletion elevenlabs-sdk/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ plugins {
}

group = "io.elevenlabs"
version = "0.6.0"
version = "0.7.0"

android {
namespace = "io.elevenlabs"
Expand Down Expand Up @@ -42,6 +42,10 @@ namespace = "io.elevenlabs"
buildFeatures {
buildConfig = true
}

testOptions {
unitTests.isReturnDefaultValues = true
}
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,28 @@ class ConversationEventHandler(
if (!toolExists) {
// Notify app layer about unhandled tool call
try { onUnhandledClientToolCall?.invoke(event) } catch (_: Throwable) {}
}

val result = try {
if (!toolExists) {
ClientToolResult.failure("Tool '${event.toolName}' not registered on client")
// If no callback is registered and agent expects a response, send failure to prevent hanging
if (onUnhandledClientToolCall == null && event.expectsResponse) {
val failureEvent = OutgoingEvent.ClientToolResult(
toolCallId = event.toolCallId,
result = mapOf<String, Any>(
"success" to false,
"result" to "",
"error" to "Tool '${event.toolName}' not registered and no handler provided"
),
isError = true
)
messageCallback(failureEvent)
Log.d("ConvEventHandler", "Tool '${event.toolName}' not registered - sent automatic failure response")
} else {
toolRegistry.executeTool(event.toolName, event.parameters)
Log.d("ConvEventHandler", "Tool '${event.toolName}' not registered - waiting for manual response via sendToolResult()")
}
return@launch
}

val result = try {
toolRegistry.executeTool(event.toolName, event.parameters)
} catch (e: Exception) {
ClientToolResult.failure("Tool execution failed: ${e.message}")
}
Expand Down Expand Up @@ -291,6 +305,23 @@ class ConversationEventHandler(
messageCallback(event)
}

/**
* Send the result of a client tool execution back to the agent
*
* @param toolCallId The unique identifier for the tool call
* @param result Map containing the result data
* @param isError Whether the tool execution resulted in an error
*/
fun sendToolResult(toolCallId: String, result: Map<String, Any>, isError: Boolean = false) {
val toolResultEvent = OutgoingEvent.ClientToolResult(
toolCallId = toolCallId,
result = result,
isError = isError
)
messageCallback(toolResultEvent)
Log.d("ConvEventHandler", "Sent tool result for call ID: $toolCallId (${if (isError) "ERROR" else "SUCCESS"})")
}

/**
* Send feedback for the last agent response
*
Expand Down
13 changes: 13 additions & 0 deletions elevenlabs-sdk/src/main/java/io/elevenlabs/ConversationSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ interface ConversationSession {
*/
fun sendUserActivity()

/**
* Send the result of a client tool execution back to the agent
*
* This method is useful for handling dynamic client tools that are not registered
* in the ClientToolRegistry. When using onUnhandledClientToolCall callback, you can
* execute your tool and then send the result back using this method.
*
* @param toolCallId The unique identifier for the tool call (from ClientToolCall event)
* @param result Map containing the result data
* @param isError Whether the tool execution resulted in an error
*/
fun sendToolResult(toolCallId: String, result: Map<String, Any>, isError: Boolean = false)

// Audio Control Methods

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ internal class ConversationSessionImpl(
eventHandler.sendUserActivity()
}

override fun sendToolResult(toolCallId: String, result: Map<String, Any>, isError: Boolean) {
eventHandler.sendToolResult(toolCallId, result, isError)
}

override fun getId(): String? = conversationId

override suspend fun toggleMute() {
Expand Down
128 changes: 128 additions & 0 deletions elevenlabs-sdk/src/test/java/io/elevenlabs/DynamicClientToolTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package io.elevenlabs

import io.elevenlabs.audio.AudioManager
import io.elevenlabs.models.ConversationEvent
import io.elevenlabs.network.OutgoingEvent
import io.mockk.*
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.Assert.*

/**
* Tests for dynamic client tool handling functionality
*
* This test suite verifies:
* 1. sendToolResult() method correctly sends tool results
* 2. Tool result event structure and serialization
* 3. Support for complex nested result structures
*/
class DynamicClientToolTest {

private lateinit var audioManager: AudioManager
private lateinit var toolRegistry: ClientToolRegistry
private lateinit var messageCallback: (OutgoingEvent) -> Unit
private lateinit var capturedEvents: MutableList<OutgoingEvent>

@Before
fun setup() {
audioManager = mockk(relaxed = true)
toolRegistry = ClientToolRegistry()
capturedEvents = mutableListOf()
messageCallback = { event ->
capturedEvents.add(event)
}
}

@After
fun teardown() {
toolRegistry.cleanup()
}

@Test
fun `sendToolResult sends correct event with success result`() {
val eventHandler = ConversationEventHandler(
audioManager = audioManager,
toolRegistry = toolRegistry,
messageCallback = messageCallback
)

val result = mapOf(
"data" to "test data",
"count" to 42
)

eventHandler.sendToolResult(
toolCallId = "tool-123",
result = result,
isError = false
)

assertEquals(1, capturedEvents.size)
val event = capturedEvents[0] as OutgoingEvent.ClientToolResult
assertEquals("tool-123", event.toolCallId)
assertEquals(result, event.result)
assertFalse(event.isError)
}

@Test
fun `sendToolResult sends correct event with error result`() {
val eventHandler = ConversationEventHandler(
audioManager = audioManager,
toolRegistry = toolRegistry,
messageCallback = messageCallback
)

val errorResult = mapOf(
"error" to "Something went wrong"
)

eventHandler.sendToolResult(
toolCallId = "tool-456",
result = errorResult,
isError = true
)

assertEquals(1, capturedEvents.size)
val event = capturedEvents[0] as OutgoingEvent.ClientToolResult
assertEquals("tool-456", event.toolCallId)
assertEquals(errorResult, event.result)
assertTrue(event.isError)
}

@Test
fun `sendToolResult with complex nested result structure`() {
val eventHandler = ConversationEventHandler(
audioManager = audioManager,
toolRegistry = toolRegistry,
messageCallback = messageCallback
)

val complexResult = mapOf(
"status" to "completed",
"data" to mapOf(
"items" to listOf(
mapOf("id" to 1, "name" to "Item 1"),
mapOf("id" to 2, "name" to "Item 2")
),
"count" to 2
),
"metadata" to mapOf(
"timestamp" to 1234567890,
"version" to "1.0.0"
)
)

eventHandler.sendToolResult(
toolCallId = "complex-789",
result = complexResult,
isError = false
)

assertEquals(1, capturedEvents.size)
val event = capturedEvents[0] as OutgoingEvent.ClientToolResult
assertEquals("complex-789", event.toolCallId)
assertEquals(complexResult, event.result)
assertFalse(event.isError)
}
}