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
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ import com.embabel.chat.ToolCall
* Read-only observer for individual tool call events.
*
* Provides observation of tool execution without access to conversation history
* or iteration state. Works in both streaming mode (where the framework manages
* the tool loop internally) and non-streaming mode (as a lightweight alternative
* or iteration state. Works in both streaming and non-streaming modes as a lightweight alternative
* to [ToolLoopInspector] when history/iteration context is not needed).
*
* @see ToolLoopInspector for tool loop-level inspection with full conversation context
Expand All @@ -49,10 +48,9 @@ interface ToolCallInspector {
* Lightweight context without conversation history or iteration state.
* Used in both streaming and non-streaming modes.
*
* **Note:** In streaming mode (Spring AI), `toolCall.id` is a generated UUID
* because the underlying framework does not expose LLM-assigned call IDs during
* tool execution. The ID is unique per call and can be used to correlate
* before/after events for the same tool execution.
* In streaming mode, adapters preserve the provider-assigned tool-call ID when the
* provider supplies one. The same ID is passed to both callbacks so inspectors can
* correlate the execution without depending on a specific provider.
*
* @property toolCall The tool call about to be executed
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,43 +17,85 @@ package com.embabel.agent.spi.loop.streaming

import com.embabel.agent.api.tool.Tool
import com.embabel.agent.api.tool.callback.ToolCallInspector
import com.embabel.agent.core.Usage
Comment thread
arnabnandy7 marked this conversation as resolved.
import com.embabel.agent.spi.loop.LlmMessageSender
import com.embabel.agent.spi.loop.ToolLoop
import com.embabel.chat.AssistantMessage
import com.embabel.chat.Message
import reactor.core.publisher.Flux

/**
* Provider-neutral events emitted by one streaming LLM inference call.
*
* [Content] events are forwarded as soon as they arrive. [Complete] is emitted once,
* after provider-specific fragments (including partial tool calls) have been assembled.
*
* This SPI type is deliberately distinct from
* [com.embabel.common.core.streaming.StreamingEvent], which is the public, converted
* application stream of thinking and typed objects. These events exist below that layer:
* [DefaultStreamingToolLoop] consumes [Complete] internally and forwards only content.
*/
sealed interface LlmInferenceStreamEvent {
data class Content(val text: String) : LlmInferenceStreamEvent

data class Complete(
val message: Message,
val usage: Usage? = null,
) : LlmInferenceStreamEvent
}

/**
* Framework-agnostic interface for streaming LLM inference.
*
* Streaming counterpart of [LlmMessageSender]. Implementations handle the actual
* LLM communication (Spring AI, LangChain4j, etc.) and return a reactive stream
* of raw content chunks.
* LLM communication (Spring AI, LangChain4j, etc.). The original [stream] contract
* remains the single abstract method for source and binary compatibility. The new
* [streamInference] method exposes the assembled terminal message required by an
* Embabel-owned streaming tool loop.
*
* **Key Differences from Non-Streaming:**
* - Returns `Flux<String>` instead of `LlmMessageResponse`
* - Tool execution is managed by the underlying framework (e.g., Spring AI)
* since the streaming API is opaque - we cannot inject a custom [ToolLoop]
* - Only observation of tool execution is possible via [ToolCallInspector]
* - Emits content incrementally, followed by the assembled assistant message
* - Does not execute tools; [ToolLoop] remains under Embabel's control
* - Provider adapters are responsible for assembling partial tool-call fragments
*
* @see LlmMessageSender for non-streaming equivalent
* @see ToolCallInspector for tool execution observation
*/
fun interface LlmMessageStreamer {

/**
* Stream raw content chunks from the LLM.
*
* The returned Flux emits content as it arrives from the LLM.
* Tool calls are handled internally by the underlying framework.
* Stream raw content chunks using the original provider-managed tool contract.
*
* @param messages The conversation history
* @param tools Available tools for the LLM to invoke during streaming
* @param toolCallInspectors Inspectors to observe tool call events
* @param toolCallInspectors Inspectors for provider-managed tool execution
* @return Flux of raw content chunks
*/
fun stream(
messages: List<Message>,
tools: List<Tool>,
toolCallInspectors: List<ToolCallInspector>,
Comment thread
arnabnandy7 marked this conversation as resolved.
): Flux<String>

/**
* Stream one inference without delegating tool execution to the provider.
*
* Provider adapters should override this method to emit an assembled [Complete]
* event containing any requested tool calls. The default bridge keeps existing
* [LlmMessageStreamer] implementations usable: their raw stream is replayed as
* [Content] and summarized as a terminal assistant message. Such legacy adapters
* retain their provider-managed tool behavior and cannot expose assembled tool calls
* to [DefaultStreamingToolLoop] until they override this method.
*/
fun streamInference(
messages: List<Message>,
tools: List<Tool>,
): Flux<LlmInferenceStreamEvent> = Flux.defer {
val content = stream(messages, tools, emptyList()).cache()
Flux.concat(
content.map<LlmInferenceStreamEvent> { LlmInferenceStreamEvent.Content(it) },
content.collectList().map<LlmInferenceStreamEvent> {
LlmInferenceStreamEvent.Complete(AssistantMessage(it.joinToString("")))
},
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/*
* Copyright 2024-2026 Embabel Pty Ltd.
*
* 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 com.embabel.agent.spi.loop.streaming

import com.embabel.agent.api.tool.Tool
import com.embabel.agent.api.tool.ToolCallContext
import com.embabel.agent.api.tool.callback.ToolCallInspector
import com.embabel.agent.spi.loop.AutoCorrectionPolicy
import com.embabel.agent.spi.loop.MaxIterationsExceededException
import com.embabel.agent.spi.loop.ToolInjectionStrategy
import com.embabel.agent.spi.loop.ToolNotFoundAction
import com.embabel.agent.spi.loop.ToolNotFoundPolicy
import com.embabel.agent.spi.loop.support.applyToolInjection
import com.embabel.agent.spi.loop.support.executeTool
import com.embabel.chat.AssistantMessageWithToolCalls
import com.embabel.chat.Message
import com.embabel.chat.ToolCall
import com.embabel.chat.ToolResultMessage
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import tools.jackson.databind.ObjectMapper

/**
* Provider-neutral counterpart of [com.embabel.agent.spi.loop.ToolLoop].
*
* Implementations manage tool execution and conversation continuation while delegating
* each individual streaming inference to an [LlmMessageStreamer]. The returned [Flux]
* contains content from every inference turn in arrival order; provider-level completion
* events remain internal to the loop.
*/
fun interface StreamingToolLoop {
fun execute(initialMessages: List<Message>, initialTools: List<Tool>): Flux<String>
Comment thread
arnabnandy7 marked this conversation as resolved.

companion object {
/**
* Create the default provider-neutral streaming tool loop while keeping its
* implementation internal to Embabel's SPI surface.
*/
@JvmStatic
@JvmOverloads
fun create(
messageStreamer: LlmMessageStreamer,
objectMapper: ObjectMapper,
injectionStrategy: ToolInjectionStrategy = ToolInjectionStrategy.NONE,
maxIterations: Int = 20,
toolDecorator: ((Tool) -> Tool)? = null,
toolCallInspectors: List<ToolCallInspector> = emptyList(),
toolCallContext: ToolCallContext = ToolCallContext.EMPTY,
toolNotFoundPolicy: ToolNotFoundPolicy = AutoCorrectionPolicy(),
): StreamingToolLoop = DefaultStreamingToolLoop(
messageStreamer = messageStreamer,
objectMapper = objectMapper,
injectionStrategy = injectionStrategy,
maxIterations = maxIterations,
toolDecorator = toolDecorator,
toolCallInspectors = toolCallInspectors,
toolCallContext = toolCallContext,
toolNotFoundPolicy = toolNotFoundPolicy,
)
}
}

/**
* Default Embabel-owned streaming tool loop.
*
* Code flow for each subscription:
* 1. Create isolated mutable conversation and tool state.
* 2. Ask [LlmMessageStreamer] to perform one inference.
* 3. Forward [LlmInferenceStreamEvent.Content] immediately.
* 4. Use [LlmInferenceStreamEvent.Complete] to append the assembled assistant message.
* 5. If the assistant requested tools, execute them, append results, apply
Comment thread
arnabnandy7 marked this conversation as resolved.
* [ToolInjectionStrategy], and recursively start the next inference.
* 6. Complete when the assistant requests no more tools, or emit direct tool content
* when a tool has `returnDirect=true`.
*
* `Flux.defer` is deliberately used at the entry point and for every turn. It creates
* state at subscription time and prevents conversation history, iteration counters, or
* dynamically injected tools from leaking between multiple subscriptions to the same Flux.
* `Flux.concatMap` preserves the ordering between content chunks, terminal processing,
* tool execution, and the subsequent inference.
*
* Multiple tool calls in one assistant message are executed sequentially, matching
* [com.embabel.agent.spi.loop.support.DefaultToolLoop]. This preserves declared order
* and deterministic `returnDirect`, inspector, and dynamic-injection semantics. Parallel
* execution would require a separate contract for those ordering-sensitive behaviours.
*
* @param maxIterations Upper bound for inference turns. Production wiring supplies
* [com.embabel.agent.core.support.LlmInteraction.maxToolIterations]; `20` is only the
* direct-construction default.
*/
internal class DefaultStreamingToolLoop(
private val messageStreamer: LlmMessageStreamer,
private val objectMapper: ObjectMapper,
private val injectionStrategy: ToolInjectionStrategy = ToolInjectionStrategy.NONE,
private val maxIterations: Int = 20,
Comment thread
arnabnandy7 marked this conversation as resolved.
private val toolDecorator: ((Tool) -> Tool)? = null,
private val toolCallInspectors: List<ToolCallInspector> = emptyList(),
private val toolCallContext: ToolCallContext = ToolCallContext.EMPTY,
private val toolNotFoundPolicy: ToolNotFoundPolicy = AutoCorrectionPolicy(),
) : StreamingToolLoop {

private val logger = LoggerFactory.getLogger(javaClass)

override fun execute(
initialMessages: List<Message>,
initialTools: List<Tool>,
): Flux<String> = Flux.defer {
streamTurn(
State(
conversationHistory = initialMessages.toMutableList(),
availableTools = initialTools.toMutableList(),
)
)
}

private fun streamTurn(state: State): Flux<String> = Flux.defer {
Comment thread
arnabnandy7 marked this conversation as resolved.
if (state.iterations >= maxIterations) {
return@defer Flux.error(MaxIterationsExceededException(maxIterations))
}
state.iterations++
logger.debug(
"Streaming tool loop iteration {} with {} available tools",
state.iterations,
state.availableTools.size,
)

messageStreamer.streamInference(
messages = state.conversationHistory.toList(),
tools = state.availableTools.toList(),
).concatMap { event ->
when (event) {
is LlmInferenceStreamEvent.Content -> Flux.just(event.text)
is LlmInferenceStreamEvent.Complete -> continueFrom(event, state)
}
}
}

/**
* Processes the assembled terminal message for one inference turn.
*
* Content was already forwarded as [LlmInferenceStreamEvent.Content], so an assistant
* response without tool calls contributes no additional item and completes with
* [Flux.empty]. Tool calls append their results to the conversation before the next
* inference starts. A direct-return tool instead emits its result and ends the loop.
*/
private fun continueFrom(
Comment thread
arnabnandy7 marked this conversation as resolved.
event: LlmInferenceStreamEvent.Complete,
state: State,
): Flux<String> {
state.conversationHistory.add(event.message)
val assistant = event.message as? AssistantMessageWithToolCalls
?: return Flux.empty()
Comment thread
arnabnandy7 marked this conversation as resolved.
if (assistant.toolCalls.isEmpty()) return Flux.empty()

for (toolCall in assistant.toolCalls) {
val directResult = executeToolCall(toolCall, state)
if (directResult != null) return Flux.just(directResult)
}
return streamTurn(state)
}

/**
* Executes one tool call and returns direct content when the tool short-circuits the loop.
*/
private fun executeToolCall(toolCall: ToolCall, state: State): String? {
Comment thread
arnabnandy7 marked this conversation as resolved.
val tool = state.availableTools.find { it.definition.name == toolCall.name }
Comment thread
arnabnandy7 marked this conversation as resolved.
if (tool == null) {
when (val action = toolNotFoundPolicy.handle(toolCall.name, state.availableTools)) {
is ToolNotFoundAction.Throw -> throw action.exception
is ToolNotFoundAction.FeedbackToModel -> {
logger.warn(action.message)
state.conversationHistory.add(
ToolResultMessage(toolCall.id, toolCall.name, action.message)
)
return null
}
}
}

toolNotFoundPolicy.onToolFound()
logger.debug("Executing streaming tool: {}", toolCall.name)
val executed = executeTool(
tool = tool,
toolCall = toolCall,
toolCallContext = toolCallContext,
toolCallInspectors = toolCallInspectors,
)

applyInjection(toolCall, executed.content, state)
state.conversationHistory.add(ToolResultMessage(toolCall.id, toolCall.name, executed.content))
if (tool.metadata.returnDirect) {
logger.info("Tool '{}' has returnDirect=true — completing streaming loop", toolCall.name)
return executed.content
}
return null
}

private fun applyInjection(toolCall: ToolCall, resultContent: String, state: State) {
applyToolInjection(
toolCall = toolCall,
resultContent = resultContent,
conversationHistory = state.conversationHistory,
availableTools = state.availableTools,
iteration = state.iterations,
injectionStrategy = injectionStrategy,
objectMapper = objectMapper,
toolDecorator = toolDecorator,
logger = logger,
)
}

private data class State(
val conversationHistory: MutableList<Message>,
val availableTools: MutableList<Tool>,
var iterations: Int = 0,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import com.embabel.common.ai.model.*
import com.embabel.common.ai.prompt.KnowledgeCutoffDate
import com.embabel.common.ai.prompt.PromptContributor
import tools.jackson.databind.annotation.JsonSerialize
import org.springframework.ai.chat.client.ChatClient
import org.springframework.ai.chat.messages.UserMessage
import org.springframework.ai.chat.model.ChatModel
import org.springframework.ai.chat.model.ChatResponse
Expand Down Expand Up @@ -141,8 +140,11 @@ data class SpringAiLlmService @JvmOverloads constructor(
}

override fun createMessageStreamer(options: LlmOptions): LlmMessageStreamer {
val chatClient = ChatClient.create(chatModel)
return SpringAiLlmMessageStreamer(chatClient, convertOptions(options))
return SpringAiLlmMessageStreamer(
chatModel = chatModel,
chatOptions = convertOptions(options),
toolResponseContentAdapter = toolResponseContentAdapter,
)
}

override fun supportsStreaming(): Boolean = StreamingCapabilityVerifier.supportsStreaming(chatModel)
Expand Down
Loading
Loading