diff --git a/docs/tools-custom/function-tools.md b/docs/tools-custom/function-tools.md index 701fde8fe6..075d28a397 100644 --- a/docs/tools-custom/function-tools.md +++ b/docs/tools-custom/function-tools.md @@ -617,6 +617,11 @@ it's None) into the content of the `FunctionResponse` sent back to the LLM. with the response. For more details on using the Resume feature, see [Resume stopped agents](/runtime/resume/). + In **Kotlin**, the runner resolves the invocation from the function + response's own call ID, so you do not need to pass `invocationId` to + `runAsync`. A response whose ID matches no function call in the session + throws instead. + ??? Tip "Applies to only Java ADK" When passing `ToolContext` with Function Tools, ensure that one of the @@ -679,6 +684,12 @@ it's None) into the content of the `FunctionResponse` sent back to the LLM. --8<-- "examples/java/snippets/src/main/java/tools/LongRunningFunctionExample.java:full_code" ``` +=== "Kotlin" + + ```kotlin + --8<-- "examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt:call_reimbursement_tool" + ``` + ??? "Python complete example: File Processing Simulation" ```python @@ -694,6 +705,15 @@ it's None) into the content of the `FunctionResponse` sent back to the LLM. incoming FunctionResponse stream (progress vs. completion) for user updates. - **Final return**: The function returns the final result dictionary, which is sent in the concluding FunctionResponse to indicate completion. +- **Kotlin has no `LongRunningFunctionTool` class**: Annotate the function with + `@Tool(isLongRunning = true)`, or pass `isLongRunning = true` to a `BaseTool` + subclass. +- **Kotlin turn count**: The tool above returns a value rather than + `Unit`, so non-resumable apps send that placeholder to the model and call it a + second time, ending turn 1 in an interim reply. A resumable app pauses on the + function call with no second model call. Returning `Unit` suppresses the + placeholder response entirely, ending the turn on the function call in either + mode. ## Agent-as-a-Tool {#agent-tool} diff --git a/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt b/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt index b6260418be..5fe9607426 100644 --- a/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt +++ b/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt @@ -4,7 +4,14 @@ import com.google.adk.kt.agents.Instruction import com.google.adk.kt.agents.LlmAgent import com.google.adk.kt.annotations.Param import com.google.adk.kt.annotations.Tool +import com.google.adk.kt.events.Event import com.google.adk.kt.models.Gemini +import com.google.adk.kt.runners.Runner +import com.google.adk.kt.types.Content +import com.google.adk.kt.types.FunctionResponse +import com.google.adk.kt.types.Part +import com.google.adk.kt.types.Role +import kotlinx.coroutines.flow.toList // --8<-- [start:long_running_tool] data class ReimbursementApproval( @@ -47,3 +54,77 @@ fun main() { ) } // --8<-- [end:long_running_tool] + +// --8<-- [start:call_reimbursement_tool] + +/** + * Drives the approval from the client side. + * + * `askForApproval` is long running, so its return value is only a `pending` + * placeholder: the real decision arrives out of band and is handed back on a + * later turn as a `FunctionResponse`. + * + * What turn 1 looks like depends on the app. Because this tool returns a value + * rather than `Unit`, a non-resumable app emits the placeholder as a function + * response and calls the model a second time, so the user sees an interim reply + * before the decision exists. A resumable app pauses on the function call + * instead, with no second model call. + */ +suspend fun approveReimbursement( + runner: Runner, + userId: String, + sessionId: String, +) { + val firstTurn = + runner + .runAsync( + userId = userId, + sessionId = sessionId, + newMessage = Content.fromText(Role.USER, "Please reimburse 200 USD for meals."), + ).toList() + firstTurn.printText() + + // A pending call is one whose id the event also lists in longRunningToolIds. + val pendingCall = + firstTurn.firstNotNullOfOrNull { event -> + event.functionCalls().firstOrNull { it.id != null && it.id in event.longRunningToolIds } + } + if (pendingCall == null) { + println("The model answered without calling the tool; nothing to approve.") + return + } + + // The id is not optional bookkeeping: the framework matches the response to + // the original call by id, and a missing or unknown one throws rather than + // degrading. It also tells a resumable app which invocation to resume, so no + // invocationId argument is needed here. + val approval = + Content( + role = Role.USER, + parts = + listOf( + Part( + functionResponse = + FunctionResponse( + name = pendingCall.name, + id = pendingCall.id, + response = mapOf("status" to "approved", "approver" to "Sean Zhou"), + ), + ), + ), + ) + + runner + .runAsync( + userId = userId, + sessionId = sessionId, + newMessage = approval, + ).toList() + .printText() +} + +private fun List.printText() = + forEach { event -> + event.content?.parts?.forEach { part -> part.text?.let(::println) } + } +// --8<-- [end:call_reimbursement_tool]