Step 4 of 5
Streaming LLM responses, concurrent tool calls with coroutines, Flow for streaming
Streaming และ async — ส่ง response ทีละ token, concurrent tool calls
A normal agent.run(...) blocks until the whole answer is ready. For long answers that feels slow. Streaming sends the answer in small pieces (tokens) so you can print text as it arrives.
Koog streams output as a Kotlin Flow<StreamFrame>. Because it is a Flow, you get backpressure, cancellation, and composition for free.
Diagram: Streaming sends many small
TextDeltaframes, then a finalEndframe.
Loading diagram...
Koog defines a sealed StreamFrame. The important ones:
StreamFrame.TextDelta(text) — one piece of assistant text (show this live)StreamFrame.TextComplete(text) — the full text, after all deltasStreamFrame.ToolCallComplete(name, content) — a finished tool callStreamFrame.End(finishReason) — the stream is overAdd the EventHandler feature and react to each frame. This is the simplest way to stream in an agent:
val agent = AIAgent(
promptExecutor = simpleOpenAIExecutor(apiKey),
llmModel = OpenAIModels.Chat.GPT4o
) {
handleEvents {
onLLMStreamingFrameReceived { ctx ->
when (val frame = ctx.streamFrame) {
is StreamFrame.TextDelta -> print(frame.text)
is StreamFrame.ToolCallComplete ->
println("\n[tool call: ${frame.name}]")
is StreamFrame.End -> println("\n[stream done]")
else -> {}
}
}
onLLMStreamingFailed { ctx ->
println("Stream error: ${ctx.error}")
}
}
}
Expected behavior as the answer is produced:
Hel|lo!| How| can| I| help?
[stream done]
The | marks where each TextDelta arrives. The user sees text appear word by word.
If you only want text, Koog gives helpers on the stream:
frames.filterTextOnly().collect { chunk -> print(chunk) } // live
val full = frames.collectText() // after End
Tools run inside coroutines, so independent calls can run at the same time. Use async and awaitAll:
import kotlinx.coroutines.*
suspend fun fetchAll(cities: List<String>): List<String> = coroutineScope {
cities.map { city ->
async { agent.run("What is the weather in $city?") }
}.awaitAll()
}
Three cities queried in parallel take about as long as one.
Coroutines give you structured cancellation. If the user stops waiting, you cancel the work. Use withTimeout to set a hard limit:
import kotlinx.coroutines.*
suspend fun askWithTimeout(question: String): String = runCatching {
withTimeout(15.seconds) {
agent.run(question)
}
}.getOrElse { "Timed out or cancelled." }
When the timeout fires, Koog's Flow is cancelled and the in-flight LLM request is stopped. This prevents one slow call from hanging your app.
withTimeout for every external LLM callasync/awaitAllCoroutineScope per request so cancellation cleans up everythingTextDelta without a newline.agent.run(...) in withTimeout(10.seconds) and trigger the timeout with a very long prompt.