-
Notifications
You must be signed in to change notification settings - Fork 402
Generate Stream with Thinking #1873
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
igordayen
wants to merge
1
commit into
main
Choose a base branch
from
streaming-converter-refactoring
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
141 changes: 141 additions & 0 deletions
141
...src/test/kotlin/com/embabel/agent/spi/support/streaming/StreamingLlmOperationsImplTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| /* | ||
| * 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.support.streaming | ||
|
|
||
| import com.embabel.agent.api.common.InteractionId | ||
| import com.embabel.agent.core.AgentProcess | ||
| import com.embabel.agent.core.support.LlmInteraction | ||
| import com.embabel.agent.spi.LlmService | ||
| import com.embabel.agent.spi.ToolDecorator | ||
| import com.embabel.agent.spi.loop.streaming.LlmMessageStreamer | ||
| import com.embabel.chat.UserMessage | ||
| import com.embabel.common.core.streaming.StreamingEvent | ||
| import com.embabel.common.core.streaming.ThinkingState | ||
| import io.mockk.every | ||
| import io.mockk.mockk | ||
| import tools.jackson.module.kotlin.jacksonObjectMapper | ||
| import org.junit.jupiter.api.Assertions.assertEquals | ||
| import org.junit.jupiter.api.Assertions.assertTrue | ||
| import org.junit.jupiter.api.BeforeEach | ||
| import org.junit.jupiter.api.Nested | ||
| import org.junit.jupiter.api.Test | ||
| import reactor.core.publisher.Flux | ||
|
|
||
| class StreamingLlmOperationsImplTest { | ||
|
|
||
| private lateinit var llmService: LlmService<*> | ||
| private lateinit var toolDecorator: ToolDecorator | ||
| private lateinit var agentProcess: AgentProcess | ||
|
|
||
| private val interaction = LlmInteraction(id = InteractionId("test")) | ||
| private val messages = listOf(UserMessage("hello")) | ||
|
|
||
| @BeforeEach | ||
| fun setUp() { | ||
| llmService = mockk(relaxed = true) | ||
| toolDecorator = mockk(relaxed = true) | ||
| agentProcess = mockk(relaxed = true) | ||
| every { llmService.promptContributors } returns emptyList() | ||
| } | ||
|
|
||
| private fun implWith(vararg chunks: String): StreamingLlmOperationsImpl { | ||
| val streamer = LlmMessageStreamer { _, _, _ -> Flux.fromArray(chunks) } | ||
| return StreamingLlmOperationsImpl( | ||
| messageStreamer = streamer, | ||
| objectMapper = jacksonObjectMapper(), | ||
| llmService = llmService, | ||
| toolDecorator = toolDecorator, | ||
| ) | ||
| } | ||
|
|
||
| private fun run(impl: StreamingLlmOperationsImpl): List<StreamingEvent<String>> = | ||
| impl.generateStreamWithThinking(messages, interaction, agentProcess, null) | ||
| .collectList().block()!! | ||
|
|
||
| @Nested | ||
| inner class GenerateStreamWithThinking { | ||
|
|
||
| @Nested | ||
| inner class LineBuffering { | ||
|
|
||
| @Test | ||
| fun `chunks split across emissions are reassembled before classification`() { | ||
| // "<think>\n" arrives as three separate chunks, none of which is a complete line alone | ||
| val events = run(implWith("<th", "ink", ">\n", "reasoning\n", "</think>\n")) | ||
| assertEquals(3, events.size) | ||
| assertEquals(ThinkingState.START, (events[0] as StreamingEvent.Thinking).state) | ||
| assertEquals(ThinkingState.CONTINUATION, (events[1] as StreamingEvent.Thinking).state) | ||
| assertEquals(ThinkingState.END, (events[2] as StreamingEvent.Thinking).state) | ||
| } | ||
|
|
||
| @Test | ||
| fun `multiple newlines in one chunk emit multiple events`() { | ||
| val events = run(implWith("line one\nline two\n")) | ||
| assertEquals(2, events.size) | ||
| } | ||
|
|
||
| @Test | ||
| fun `trailing content without newline is flushed at stream end`() { | ||
| // no trailing \n — rawChunksToLines flushes the buffer on complete | ||
| val events = run(implWith("hello world")) | ||
| assertEquals(1, events.size) | ||
| assertEquals("hello world", (events[0] as StreamingEvent.Thinking).content) | ||
| } | ||
| } | ||
|
|
||
| @Nested | ||
| inner class ThinkingClassification { | ||
|
|
||
| @Test | ||
| fun `complete think block emits BOTH with tags stripped`() { | ||
| val events = run(implWith("<think>reasoning</think>\n")) | ||
| assertEquals(1, events.size) | ||
| val event = events[0] as StreamingEvent.Thinking | ||
| assertEquals("reasoning", event.content) | ||
| assertEquals(ThinkingState.BOTH, event.state) | ||
| } | ||
|
|
||
| @Test | ||
| fun `multi-line think block emits START then CONTINUATION then END`() { | ||
| val events = run(implWith("<think>\n", "mid\n", "</think>\n")) | ||
| assertEquals(3, events.size) | ||
| assertEquals(ThinkingState.START, (events[0] as StreamingEvent.Thinking).state) | ||
| assertEquals(ThinkingState.CONTINUATION, (events[1] as StreamingEvent.Thinking).state) | ||
| assertEquals(ThinkingState.END, (events[2] as StreamingEvent.Thinking).state) | ||
| } | ||
|
|
||
| @Test | ||
| fun `plain prose lines emit as CONTINUATION`() { | ||
| val events = run(implWith("line one\n", "line two\n")) | ||
| assertEquals(2, events.size) | ||
| events.forEach { assertEquals(ThinkingState.CONTINUATION, (it as StreamingEvent.Thinking).state) } | ||
| } | ||
|
|
||
| @Test | ||
| fun `JSON-shaped lines are dropped`() { | ||
| val events = run(implWith("{\"key\":\"value\"}\n")) | ||
| assertTrue(events.isEmpty()) | ||
| } | ||
|
|
||
| @Test | ||
| fun `code fence lines are dropped`() { | ||
| val events = run(implWith("```json\n", "<think>reasoning</think>\n", "```\n")) | ||
| assertEquals(1, events.size) | ||
| assertEquals(ThinkingState.BOTH, (events[0] as StreamingEvent.Thinking).state) | ||
| } | ||
| } | ||
| } | ||
| } |
75 changes: 75 additions & 0 deletions
75
...-ai/src/main/kotlin/com/embabel/common/ai/converters/streaming/StreamingLineClassifier.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| /* | ||
| * 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.common.ai.converters.streaming | ||
|
|
||
| import com.embabel.common.ai.converters.streaming.support.ThinkingDetector | ||
| import com.embabel.common.core.streaming.StreamingEvent | ||
| import com.embabel.common.core.streaming.ThinkingState | ||
| import reactor.core.publisher.Flux | ||
|
|
||
| /** | ||
| * Routes a single newline-delimited line from an LLM stream to the appropriate [StreamingEvent]. | ||
| * | ||
| * Separates Reactor event-routing from pure thinking detection ([ThinkingDetector]), | ||
| * so raw-string streaming shares the same dispatch logic in one place. | ||
| */ | ||
| object StreamingLineClassifier { | ||
|
|
||
| // Matches bare markdown code-fence lines such as ```json or ``` that LLMs emit | ||
| // as formatting artifacts between thinking blocks and JSON. These carry no content | ||
| // and must be dropped before the thinking path sees them. | ||
| // Aligns with the equivalent inline check in [StreamingJacksonOutputConverter.convertStreamWithThinking]. | ||
| private val codeFencePattern = Regex("^```\\w*$") | ||
|
|
||
| /** | ||
| * Classify a single [line] from a newline-delimited LLM stream into zero or one [StreamingEvent.Thinking]. | ||
| * | ||
| * Every line arriving from the LLM is either: | ||
| * - **Thinking content** — wrapped in a tag such as `<think>...</think>` or a partial | ||
| * multi-line variant. These become [StreamingEvent.Thinking] events carrying the extracted | ||
| * text and a [ThinkingState] that tells the consumer whether this is a complete block, | ||
| * the start, a continuation, or the end of a multi-line block. | ||
| * - **A bare code fence** (e.g. ` ```json ` or ` ``` `) — a formatting artifact emitted | ||
| * by some models between thinking blocks and output. Always dropped. | ||
| * - **Anything else** — dropped; [StreamingEvent.Object] is not emitted in the | ||
| * raw-string streaming context this classifier serves. | ||
| * | ||
| * Detection is delegated to [ThinkingDetector]; this class only owns the Reactor mapping. | ||
| * | ||
| * @param line a complete newline-delimited line from the LLM stream (no trailing newline) | ||
| * @return a [Flux] of at most one [StreamingEvent.Thinking], or empty when the line is dropped | ||
| */ | ||
| fun classify(line: String): Flux<StreamingEvent<String>> { | ||
| // Ask ThinkingDetector to classify the line. NONE means non-thinking content (dropped); | ||
| // anything else (BOTH, START, CONTINUATION, END) means the line contains thinking markup. | ||
| val state = ThinkingDetector.detectThinkingState(line) | ||
|
|
||
| return when (state) { | ||
| // Non-thinking line (e.g. a stray JSON line) — not expected in raw-string streaming. | ||
| ThinkingState.NONE -> Flux.empty() | ||
|
|
||
| // Thinking content line — but first filter out bare code fences (``` / ```json) | ||
| // which are formatting artifacts that must not leak into thinking events. | ||
| else -> if (!line.trim().matches(codeFencePattern)) | ||
| // Extract the actual thinking text (strips surrounding tags when present) | ||
| // and emit a Thinking event with the detected state for multi-line tracking. | ||
| Flux.just(StreamingEvent.Thinking(ThinkingDetector.extractThinkingContent(line), state)) | ||
| else | ||
| // Bare code fence — drop silently. | ||
| Flux.empty() | ||
| } | ||
| } | ||
| } |
118 changes: 118 additions & 0 deletions
118
...src/test/kotlin/com/embabel/common/ai/converters/streaming/StreamingLineClassifierTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| /* | ||
| * 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.common.ai.converters.streaming | ||
|
|
||
| import com.embabel.common.core.streaming.StreamingEvent | ||
| import com.embabel.common.core.streaming.ThinkingState | ||
| import org.junit.jupiter.api.Assertions.assertEquals | ||
| import org.junit.jupiter.api.Assertions.assertTrue | ||
| import org.junit.jupiter.api.Nested | ||
| import org.junit.jupiter.api.Test | ||
|
|
||
| class StreamingLineClassifierTest { | ||
|
|
||
| private fun classify(line: String): List<StreamingEvent<String>> = | ||
| StreamingLineClassifier.classify(line).collectList().block()!! | ||
|
|
||
| @Nested | ||
| inner class PlainText { | ||
|
|
||
| @Test | ||
| fun `plain text emits Thinking with CONTINUATION state`() { | ||
| val events = classify("aaaaaa") | ||
| assertEquals(1, events.size) | ||
| val event = events[0] as StreamingEvent.Thinking | ||
| assertEquals("aaaaaa", event.content) | ||
| assertEquals(ThinkingState.CONTINUATION, event.state) | ||
| } | ||
|
|
||
| @Test | ||
| fun `text with embedded opening tag not at start emits CONTINUATION`() { | ||
| // "aaaaa<think>nnnnn" does not start with <think>, so not detected as START | ||
| val events = classify("aaaaa<think>nnnnn") | ||
| assertEquals(1, events.size) | ||
| val event = events[0] as StreamingEvent.Thinking | ||
| assertEquals("aaaaa<think>nnnnn", event.content) | ||
| assertEquals(ThinkingState.CONTINUATION, event.state) | ||
| } | ||
| } | ||
|
|
||
| @Nested | ||
| inner class ThinkingTags { | ||
|
|
||
| @Test | ||
| fun `complete think block strips tags and emits BOTH`() { | ||
| val events = classify("<think>xyz</think>") | ||
| assertEquals(1, events.size) | ||
| val event = events[0] as StreamingEvent.Thinking | ||
| assertEquals("xyz", event.content) | ||
| assertEquals(ThinkingState.BOTH, event.state) | ||
| } | ||
|
|
||
| @Test | ||
| fun `standalone opening tag emits START`() { | ||
| val events = classify("<think>") | ||
| assertEquals(1, events.size) | ||
| val event = events[0] as StreamingEvent.Thinking | ||
| assertEquals(ThinkingState.START, event.state) | ||
| } | ||
|
|
||
| @Test | ||
| fun `line starting with opening tag and content emits START`() { | ||
| val events = classify("<think>start of reasoning") | ||
| assertEquals(1, events.size) | ||
| val event = events[0] as StreamingEvent.Thinking | ||
| assertEquals(ThinkingState.START, event.state) | ||
| } | ||
|
|
||
| @Test | ||
| fun `standalone closing tag emits END`() { | ||
| val events = classify("</think>") | ||
| assertEquals(1, events.size) | ||
| val event = events[0] as StreamingEvent.Thinking | ||
| assertEquals(ThinkingState.END, event.state) | ||
| } | ||
|
|
||
| @Test | ||
| fun `text ending with closing tag emits END with tags not stripped`() { | ||
| // extractThinkingContent only strips complete <think>…</think> pairs on one line | ||
| val events = classify("nnnnn</think>") | ||
| assertEquals(1, events.size) | ||
| val event = events[0] as StreamingEvent.Thinking | ||
| assertEquals("nnnnn</think>", event.content) | ||
| assertEquals(ThinkingState.END, event.state) | ||
| } | ||
| } | ||
|
|
||
| @Nested | ||
| inner class DroppedLines { | ||
|
|
||
| @Test | ||
| fun `code fence backtick-json is dropped`() { | ||
| assertTrue(classify("```json").isEmpty()) | ||
| } | ||
|
|
||
| @Test | ||
| fun `bare code fence is dropped`() { | ||
| assertTrue(classify("```").isEmpty()) | ||
| } | ||
|
|
||
| @Test | ||
| fun `JSON-shaped line is dropped`() { | ||
| assertTrue(classify("""{"key":"value"}""").isEmpty()) | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if we have pure streaming text content, with no thinking tags included? We would still buffer that stream until we find a newline character. I don't think that is a good behavior. Would it be possible to hold of buffering until we identify a chunk that could be the start of a thinking tag?
The use-case I'm thinking of is where we use this method to get StreamingEvent but the thinking we are looking for is native thinking triggered by setting a thinking budget. (I know, not yet implemented or designed, but given the name of the methods I think it is reasonable to assume they should pick up both types of thinking.) ==> that complies with the current behavior for object creation, when Thinking by definition is having tagType=as {XML-tag, PREFIX, NO-PREFIX}. in object creation - everything that is not a JSON is modelled as thinking, see PROMPT definition, for blocking and streaming events.
In summary: replicate the same logic as for object creation and drop any object creation.
Thanks
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jorander The intent here is to model every line as a ThinkingEvent - as the method states "withThinking".
Fully aligned with object creation. Same behavior.
Native thinking is a very challenging area; eager to start after release 2.0.0, main focus this week.
Intentionally made this simple.
Headups, I'm reviewing discussion forums; new items coming. One of them is related to providing the user with both:
Thinking type is having tagType=as {XML-tag, PREFIX, NO-PREFIX}. In object creation, everything that is not JSON is modelled as thinking; see PROMPT definition for blocking and streaming events.
User can opt to use just createObject if needed; mix of thinking + String (final response)