-
Notifications
You must be signed in to change notification settings - Fork 407
feat: stream thinking with text responses #1819
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,6 +57,21 @@ interface StreamingLlmOperations { | |
| action: Action?, | ||
| ): Flux<String> | ||
|
|
||
| /** | ||
| * Generate text and thinking events from messages. | ||
| * | ||
| * The default wraps the existing text-only stream so third-party | ||
| * implementations remain source compatible. | ||
| */ | ||
| fun generateStreamWithThinking( | ||
| messages: List<Message>, | ||
| interaction: LlmInteraction, | ||
| agentProcess: AgentProcess, | ||
| action: Action?, | ||
| ): Flux<StreamingEvent<String>> = | ||
| generateStream(messages, interaction, agentProcess, action) | ||
| .map { StreamingEvent.Object(it) } | ||
|
|
||
| /** | ||
| * Create a streaming list of objects from JSONL response in the context of an AgentProcess. | ||
| * Each line in the LLM response should be a valid JSON object matching the output class. | ||
|
|
@@ -142,6 +157,21 @@ interface StreamingLlmOperations { | |
| action: Action? = null, | ||
| ): Flux<String> | ||
|
|
||
| /** | ||
| * Low-level text and thinking stream with optional platform context. | ||
| * | ||
| * The default wraps [doTransformStream] for source compatibility. | ||
| */ | ||
| fun doTransformStreamWithThinking( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default in So the default only ever applies to third-party implementors - who then silently get no thinking at all. may be change .map { StreamingEvent.Object(it) by toTaggedThinkingEvents() at line 173 and remove implementation in both file |
||
| messages: List<Message>, | ||
| interaction: LlmInteraction, | ||
| llmRequestEvent: LlmRequestEvent<String>?, | ||
| agentProcess: AgentProcess? = null, | ||
| action: Action? = null, | ||
| ): Flux<StreamingEvent<String>> = | ||
| doTransformStream(messages, interaction, llmRequestEvent, agentProcess, action) | ||
| .map { StreamingEvent.Object(it) } | ||
|
|
||
| /** | ||
| * Low level object streaming transform with optional platform context. | ||
| * Streams typed objects as they are parsed from JSONL response. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| /* | ||
| * 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.common.core.streaming.StreamingEvent | ||
| import com.embabel.common.core.thinking.ThinkingTags | ||
| import reactor.core.publisher.Flux | ||
|
|
||
| /** | ||
| * Extracts tagged thinking from text while preserving response order across | ||
| * arbitrary chunk boundaries. Parser state is scoped to each subscription. | ||
| */ | ||
| internal fun Flux<String>.toTaggedThinkingEvents(): Flux<StreamingEvent<String>> = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What prompted this very significant change? Reason for this - it's the streaming at the very end, so as not to accumulate too much. |
||
| Flux.defer { | ||
| val parser = TaggedThinkingParser() | ||
| this@toTaggedThinkingEvents | ||
| .concatMap { Flux.fromIterable(parser.accept(it)) } | ||
| .concatWith(Flux.defer { Flux.fromIterable(parser.finish()) }) | ||
| } | ||
|
|
||
| private class TaggedThinkingParser { | ||
|
|
||
| private data class Tag( | ||
| val start: String, | ||
| val end: String, | ||
| ) | ||
|
|
||
| private data class StartMatch( | ||
| val index: Int, | ||
| val tag: Tag?, | ||
| val legacy: Boolean, | ||
| ) | ||
|
|
||
| private val tags = ThinkingTags.TAG_DEFINITIONS | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The stream parser only does exact So the reasoning ends up rendered as the answer in streamed calls. Intentional scope |
||
| .filterKeys { it != "legacy_prefix" && it != "no_prefix" } | ||
| .values | ||
| .map { Tag(it.first, it.second) } | ||
|
|
||
| private val legacyPrefix = ThinkingTags.TAG_DEFINITIONS["legacy_prefix"]?.first.orEmpty() | ||
| private val buffer = StringBuilder() | ||
| private var activeTag: Tag? = null | ||
| private var atLineStart = true | ||
|
|
||
| fun accept(text: String): List<StreamingEvent<String>> { | ||
| buffer.append(text) | ||
| val events = mutableListOf<StreamingEvent<String>>() | ||
|
|
||
| while (buffer.isNotEmpty()) { | ||
| val tag = activeTag | ||
| if (tag != null) { | ||
| val endIndex = buffer.indexOf(tag.end) | ||
| if (endIndex < 0) break | ||
| val thinking = buffer.substring(0, endIndex).trim() | ||
| if (thinking.isNotEmpty()) events += StreamingEvent.Thinking(thinking) | ||
| buffer.delete(0, endIndex + tag.end.length) | ||
|
Comment on lines
+64
to
+68
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One asymmetry worth confirming: the streaming path removes tagged content from the text channel, the blocking path keeps it. Same model output, different text from |
||
| activeTag = null | ||
| atLineStart = false | ||
| continue | ||
|
Comment on lines
+63
to
+71
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #1716 is about streaming the reasoning, but It is also O(N²): the solution and the mechanism is already in your code: |
||
| } | ||
|
|
||
| if (atLineStart && legacyPrefix.isNotEmpty() && buffer.startsWith(legacyPrefix)) { | ||
| val newlineIndex = buffer.indexOf("\n") | ||
| if (newlineIndex < 0) break | ||
| val thinking = buffer.substring(legacyPrefix.length, newlineIndex).trim() | ||
| if (thinking.isNotEmpty()) events += StreamingEvent.Thinking(thinking) | ||
| buffer.delete(0, newlineIndex + 1) | ||
| atLineStart = true | ||
| continue | ||
| } | ||
|
|
||
| val match = findStart() | ||
| if (match != null) { | ||
| if (match.index > 0) { | ||
| emitText(events, buffer.substring(0, match.index)) | ||
| buffer.delete(0, match.index) | ||
| continue | ||
| } | ||
| if (match.legacy) break | ||
| activeTag = match.tag | ||
| buffer.delete(0, match.tag!!.start.length) | ||
| atLineStart = false | ||
| continue | ||
| } | ||
|
|
||
| val heldSuffixLength = longestPartialStartSuffix() | ||
| val emitLength = buffer.length - heldSuffixLength | ||
| if (emitLength > 0) { | ||
| emitText(events, buffer.substring(0, emitLength)) | ||
| buffer.delete(0, emitLength) | ||
| } | ||
| break | ||
| } | ||
|
|
||
| return events | ||
| } | ||
|
|
||
| fun finish(): List<StreamingEvent<String>> { | ||
| val events = mutableListOf<StreamingEvent<String>>() | ||
| val tag = activeTag | ||
| if (tag != null) { | ||
| emitText(events, tag.start + buffer.toString()) | ||
| } else if (atLineStart && legacyPrefix.isNotEmpty() && buffer.startsWith(legacyPrefix)) { | ||
| val thinking = buffer.substring(legacyPrefix.length).trim() | ||
| if (thinking.isNotEmpty()) events += StreamingEvent.Thinking(thinking) | ||
| } else { | ||
| emitText(events, buffer.toString()) | ||
| } | ||
| buffer.clear() | ||
| activeTag = null | ||
| atLineStart = true | ||
| return events | ||
| } | ||
|
|
||
| private fun findStart(): StartMatch? { | ||
| val tagMatch = tags | ||
| .mapNotNull { tag -> buffer.indexOf(tag.start).takeIf { it >= 0 }?.let { StartMatch(it, tag, false) } } | ||
| .minWithOrNull(compareBy<StartMatch> { it.index }.thenByDescending { it.tag?.start?.length ?: 0 }) | ||
| val legacyMatch = if (legacyPrefix.isNotEmpty()) { | ||
| findLegacyStart()?.let { StartMatch(it, null, true) } | ||
| } else { | ||
| null | ||
| } | ||
| return listOfNotNull(tagMatch, legacyMatch).minByOrNull { it.index } | ||
| } | ||
|
|
||
| private fun findLegacyStart(): Int? { | ||
| var fromIndex = 0 | ||
| while (fromIndex < buffer.length) { | ||
| val index = buffer.indexOf(legacyPrefix, fromIndex) | ||
| if (index < 0) return null | ||
| if ((index == 0 && atLineStart) || (index > 0 && buffer[index - 1] == '\n')) return index | ||
| fromIndex = index + 1 | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| private fun longestPartialStartSuffix(): Int { | ||
| val tagSuffixLength = tags.map { it.start }.maxOfOrNull { start -> | ||
| (1 until start.length) | ||
| .filter { length -> buffer.endsWith(start.substring(0, length)) } | ||
| .maxOrNull() ?: 0 | ||
| } ?: 0 | ||
| return maxOf(tagSuffixLength, longestPartialLegacySuffix()) | ||
| } | ||
|
|
||
| private fun longestPartialLegacySuffix(): Int { | ||
| if (legacyPrefix.isEmpty()) return 0 | ||
| return (1 until legacyPrefix.length) | ||
| .filter { length -> | ||
| if (!buffer.endsWith(legacyPrefix.substring(0, length))) return@filter false | ||
| val startIndex = buffer.length - length | ||
| (startIndex == 0 && atLineStart) || (startIndex > 0 && buffer[startIndex - 1] == '\n') | ||
| } | ||
| .maxOrNull() ?: 0 | ||
| } | ||
|
|
||
| private fun emitText(events: MutableList<StreamingEvent<String>>, text: String) { | ||
| if (text.isNotEmpty()) { | ||
| events += StreamingEvent.Object(text) | ||
| atLineStart = text.endsWith("\n") | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| /* | ||
| * 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.api.streaming; | ||
|
|
||
| import com.embabel.agent.api.common.streaming.StreamingPromptRunner; | ||
| import com.embabel.common.core.streaming.StreamingEvent; | ||
| import org.junit.jupiter.api.Test; | ||
| import reactor.core.publisher.Flux; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| class StreamingThinkingJavaApiTest { | ||
|
|
||
| @Test | ||
| void exposesThinkingAwareTextStreamingToJava() throws NoSuchMethodException { | ||
| var method = StreamingPromptRunner.Streaming.class.getMethod("generateStreamWithThinking"); | ||
|
|
||
| assertThat(method.getReturnType()).isEqualTo(Flux.class); | ||
| assertThat(method.isDefault()).isTrue(); | ||
| } | ||
|
|
||
| Flux<StreamingEvent<String>> callFromJava(StreamingPromptRunner.Streaming streaming) { | ||
| return streaming.generateStreamWithThinking(); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.