From a336a96d34a6a21ba26d5086ef7ce5e704fb7c64 Mon Sep 17 00:00:00 2001 From: Shahin Saadati Date: Wed, 19 Aug 2026 12:50:13 -0700 Subject: [PATCH 1/4] Show how a Kotlin client answers a long-running tool call The function-tools page documents long-running tools in two halves: defining one (which Kotlin already covered) and driving it from the client, which Kotlin did not. Nothing in the docs showed a Kotlin reader how the deferred result gets back to the model - the only Kotlin mention of longRunningToolIds in the repo is a commented-out field listing in events/index.md. The new region continues the reimbursement scenario the Kotlin tab above it already sets up, rather than importing the nav-agent scenario the upstream demos use. It shows the two things that are easy to get wrong: - A pending call is one whose id the event also lists in `longRunningToolIds`; the FunctionResponse must reuse that id or the model cannot match the answer to the request it is waiting on. - A resumable app must pass `invocationId` to the second `runAsync`. Without it the response opens a new invocation instead of resuming the paused one, which the page's own resume note warns about for Python. Grounded in ResumableLongRunningToolDemoAgent.kt:84-99 at the v0.8.0 tag. Appended to the existing, already-registered LongRunningTool.kt instead of the new file the backlog row proposed: this page already owns that snippet, and a second file elsewhere would split one page's Kotlin across two directories. Also added a bullet to "Key aspects of this example", which explains the group purely in terms of `LongRunningFunctionTool` - a class Kotlin does not have. The Kotlin form is `@Tool(isLongRunning = true)` or a `BaseTool` subclass, and a long-running tool returning `Unit` suppresses even the placeholder response (InvocationContext.kt:447). Verified: runner.sh build and lint both PASS on the snippet (JDK 17), check_kotlin_snippets.sh passes, L0/L5/L6 pass. L3 reports two orphaned-tab problems at lines 123 and 227; both pre-date this change and are false positives - rendering the page with the repo's own markdown extensions shows every group, including the one edited here, as a single tabbed set with Kotlin among its labels. --- docs/tools-custom/function-tools.md | 10 +++ .../tools/function-tools/LongRunningTool.kt | 69 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/docs/tools-custom/function-tools.md b/docs/tools-custom/function-tools.md index 701fde8fe6..98855027e3 100644 --- a/docs/tools-custom/function-tools.md +++ b/docs/tools-custom/function-tools.md @@ -679,6 +679,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 @@ -690,6 +696,10 @@ it's None) into the content of the `FunctionResponse` sent back to the LLM. - **`LongRunningFunctionTool`**: Wraps the supplied method/function; the framework handles sending yielded updates and the final return value as sequential FunctionResponses. +- **Kotlin has no `LongRunningFunctionTool` class**: annotate the function with + `@Tool(isLongRunning = true)`, or pass `isLongRunning = true` to a `BaseTool` + subclass. A long-running tool that returns `Unit` suppresses even the + placeholder response, so the turn ends on the function call alone. - **Agent instruction**: Directs the LLM to use the tool and understand the incoming FunctionResponse stream (progress vs. completion) for user updates. - **Final return**: The function returns the final result dictionary, which is diff --git a/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt b/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt index b6260418be..2564d42750 100644 --- a/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt +++ b/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt @@ -5,6 +5,12 @@ 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.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 +53,66 @@ fun main() { ) } // --8<-- [end:long_running_tool] + +// --8<-- [start:call_reimbursement_tool] + +/** + * Drives the approval from the client side. + * + * `askForApproval` is long running, so it returns a `pending` placeholder and the + * turn ends. The real decision arrives out of band and is handed back on a later + * turn as a `FunctionResponse`. + */ +suspend fun approveReimbursement( + runner: Runner, + userId: String, + sessionId: String, + // True when the app was built with ResumabilityConfig(isResumable = true). + appIsResumable: Boolean = false, +) { + val firstTurn = + runner + .runAsync( + userId = userId, + sessionId = sessionId, + newMessage = Content.fromText(Role.USER, "Please reimburse 200 USD for meals."), + ).toList() + + // 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 } + } ?: return + + // Reuse the id of the original call, or the model cannot match this answer to + // the request it is still waiting on. + 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, + // A resumable app must resume the invocation that raised the call; + // without the id the response starts a new invocation instead. A + // non-resumable app passes null and continues in a fresh invocation. + invocationId = if (appIsResumable) firstTurn.firstOrNull()?.invocationId else null, + newMessage = approval, + ).collect { event -> + event.content?.parts?.forEach { part -> part.text?.let(::println) } + } +} +// --8<-- [end:call_reimbursement_tool] From 1c48e14f84dd793374a6261d875ae30991adc933 Mon Sep 17 00:00:00 2001 From: Shahin Saadati Date: Wed, 19 Aug 2026 13:05:04 -0700 Subject: [PATCH 2/4] Correct the long-running snippet's account of resume and turn count Review against the v0.8.0 sources found three claims in this branch that a reader would have acted on and been wrong. The invocationId argument was the worst of them. The snippet took an `appIsResumable` flag and passed `invocationId` on the second `runAsync`, commenting that a resumable app must do so or the response opens a new invocation. The runner does not work that way: `resolveInvocationId` (AbstractRunner.kt:468-483) looks the id up from the function-call event that matches the response's own id and discards whatever the caller passed. The flag was inert, and anyone plumbing it through their call sites would have got nothing for it. Both are gone; the comment now says what actually resumes the invocation - the response id itself. "Returns a placeholder and the turn ends" was wrong for the snippet's own default. This tool returns a data class, not `Unit`, so a non-resumable app emits the placeholder as a function response and calls the model again: LongRunningToolIntegrationTest's scenario table records two model calls and a trailing text event for that combination, and asserts it in runAsync_longRunningToolReturnsDict_propagatesPayloadAndAcknowledges. A reader building a HITL flow would have budgeted one model call and been surprised by an interim reply. The KDoc and the page bullet now describe both modes. Reusing the call id was described as something the model needs to match the answer to its request. The model never gets that far: an unknown id throws from HistoryRewriterProcessor.findMatchingFunctionCallEvent, and a null one throws too, because the id set is built with mapNotNull and an empty set matches no event. The comment now says it throws. Also prints turn 1, which is where the interim reply appears, and says so when the model answers without calling the tool instead of returning silently. Verified: runner.sh build and lint both PASS (JDK 17), L0/L1/L2/L5/L6 pass, and rendering the page with the repo's markdown extensions puts Kotlin in the target group's tab set. L3's two orphaned-tab reports are pre-existing on main and are false positives - the render shows those groups whole. --- docs/tools-custom/function-tools.md | 9 +++- .../tools/function-tools/LongRunningTool.kt | 42 ++++++++++++------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/docs/tools-custom/function-tools.md b/docs/tools-custom/function-tools.md index 98855027e3..bf7f32b50f 100644 --- a/docs/tools-custom/function-tools.md +++ b/docs/tools-custom/function-tools.md @@ -698,8 +698,13 @@ it's None) into the content of the `FunctionResponse` sent back to the LLM. sequential FunctionResponses. - **Kotlin has no `LongRunningFunctionTool` class**: annotate the function with `@Tool(isLongRunning = true)`, or pass `isLongRunning = true` to a `BaseTool` - subclass. A long-running tool that returns `Unit` suppresses even the - placeholder response, so the turn ends on the function call alone. + subclass. +- **Kotlin turn count**: because the tool above returns a value rather than + `Unit`, a non-resumable app sends that placeholder to the model and calls it a + second time, so turn 1 ends 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 instruction**: Directs the LLM to use the tool and understand the incoming FunctionResponse stream (progress vs. completion) for user updates. - **Final return**: The function returns the final result dictionary, which is diff --git a/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt b/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt index 2564d42750..5fe9607426 100644 --- a/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt +++ b/examples/kotlin/snippets/tools/function-tools/LongRunningTool.kt @@ -4,6 +4,7 @@ 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 @@ -59,16 +60,20 @@ fun main() { /** * Drives the approval from the client side. * - * `askForApproval` is long running, so it returns a `pending` placeholder and the - * turn ends. The real decision arrives out of band and is handed back on a later - * turn as a `FunctionResponse`. + * `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, - // True when the app was built with ResumabilityConfig(isResumable = true). - appIsResumable: Boolean = false, ) { val firstTurn = runner @@ -77,15 +82,22 @@ suspend fun approveReimbursement( 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 } - } ?: return + } + if (pendingCall == null) { + println("The model answered without calling the tool; nothing to approve.") + return + } - // Reuse the id of the original call, or the model cannot match this answer to - // the request it is still waiting on. + // 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, @@ -106,13 +118,13 @@ suspend fun approveReimbursement( .runAsync( userId = userId, sessionId = sessionId, - // A resumable app must resume the invocation that raised the call; - // without the id the response starts a new invocation instead. A - // non-resumable app passes null and continues in a fresh invocation. - invocationId = if (appIsResumable) firstTurn.firstOrNull()?.invocationId else null, newMessage = approval, - ).collect { event -> - event.content?.parts?.forEach { part -> part.text?.let(::println) } - } + ).toList() + .printText() } + +private fun List.printText() = + forEach { event -> + event.content?.parts?.forEach { part -> part.text?.let(::println) } + } // --8<-- [end:call_reimbursement_tool] From 4c1a393d309c4c3a6e5c4737da3735527b821d21 Mon Sep 17 00:00:00 2001 From: Joe Fernandez <931947+joefernandez@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:39:57 -0700 Subject: [PATCH 3/4] Update function-tools.md --- docs/tools-custom/function-tools.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/tools-custom/function-tools.md b/docs/tools-custom/function-tools.md index bf7f32b50f..b7293386c1 100644 --- a/docs/tools-custom/function-tools.md +++ b/docs/tools-custom/function-tools.md @@ -696,19 +696,19 @@ it's None) into the content of the `FunctionResponse` sent back to the LLM. - **`LongRunningFunctionTool`**: Wraps the supplied method/function; the framework handles sending yielded updates and the final return value as sequential FunctionResponses. -- **Kotlin has no `LongRunningFunctionTool` class**: annotate the function with +- **Agent instruction**: Directs the LLM to use the tool and understand the + 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**: because the tool above returns a value rather than - `Unit`, a non-resumable app sends that placeholder to the model and calls it a +- **Kotlin turn count**: The tool above returns a value rather than + `Unit`, so non-resumable apps send that placeholder to the model and calls it a second time, so turn 1 ends 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 instruction**: Directs the LLM to use the tool and understand the - 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. ## Agent-as-a-Tool {#agent-tool} From 3857932014dfa445cedc8167c1ccae76276c83c7 Mon Sep 17 00:00:00 2001 From: Shahin Saadati Date: Wed, 26 Aug 2026 09:14:00 -0700 Subject: [PATCH 4/4] Say that Kotlin resolves the invocation from the response itself Adding a Kotlin tab to this section quietly extended the Resume note to Kotlin, where it does not hold: resolveInvocationId matches the function response's own call ID against the session and ignores the invocationId the caller passes, so requiring one sends readers looking for a parameter that changes nothing. An ID matching no call throws rather than starting a fresh invocation. Also fix subject-verb agreement in the turn-count bullet. --- docs/tools-custom/function-tools.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/tools-custom/function-tools.md b/docs/tools-custom/function-tools.md index b7293386c1..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 @@ -704,8 +709,8 @@ it's None) into the content of the `FunctionResponse` sent back to the LLM. `@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 calls it a - second time, so turn 1 ends in an interim reply. A resumable app pauses on the + `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.