Step start chunks are not persisted into {@link UIMessage#parts()} by the stream reader.
+ *
The stream reader persists this lifecycle event as a marker-only {@link StepStartPart}.
+ * The invocation-local index remains stream diagnostics and is not copied to the persisted part.
*
* @param stepIndex step index
*/
diff --git a/api/src/main/java/run/halo/aifoundation/ui/StepStartPart.java b/api/src/main/java/run/halo/aifoundation/ui/StepStartPart.java
new file mode 100644
index 00000000..d3e82813
--- /dev/null
+++ b/api/src/main/java/run/halo/aifoundation/ui/StepStartPart.java
@@ -0,0 +1,14 @@
+package run.halo.aifoundation.ui;
+
+/**
+ * Marker persisted at the start of one assistant generation step.
+ *
+ *
The marker's position in {@link UIMessage#parts()} defines the boundary. It intentionally
+ * carries no invocation-local step index because indexes restart for each model invocation.
+ */
+public record StepStartPart() implements UIMessagePart {
+ @Override
+ public String type() {
+ return UIMessageChunkType.STEP_START;
+ }
+}
diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java
index 5c676a7d..258b5e8c 100644
--- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java
+++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java
@@ -32,7 +32,7 @@ public UIMessageChunkReducer(List parts) {
* Applies a stream chunk.
*
* @param chunk stream chunk
- * @return true when persisted message parts changed
+ * @return true when visible persisted message content changed
*/
public boolean accept(UIMessageChunk chunk) {
UIMessageChunkValidator.validate(chunk);
@@ -74,7 +74,10 @@ public boolean accept(UIMessageChunk chunk) {
tool.providerMetadata());
case ToolChunk tool -> replaceTool(tool);
case FinishStepChunk ignored -> false;
- case StartStepChunk ignored -> false;
+ case StartStepChunk ignored -> {
+ parts.add(UIMessageParts.stepStart());
+ yield false;
+ }
case FinishChunk finish -> {
terminal = terminal.withFinish(finish.finishReason(), finish.usage());
yield false;
diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkType.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkType.java
index b58bbff2..12688e9d 100644
--- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkType.java
+++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkType.java
@@ -9,6 +9,8 @@ public final class UIMessageChunkType {
public static final String START = "start";
/** Starts one generation step. */
public static final String START_STEP = "start-step";
+ /** Persisted generation-step boundary marker. */
+ public static final String STEP_START = "step-start";
/** Persisted text part type. */
public static final String TEXT = "text";
/** Opens a streamed text block. */
diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageConverters.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageConverters.java
index 1032c509..b27d61af 100644
--- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageConverters.java
+++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageConverters.java
@@ -112,36 +112,24 @@ private void convertMessage(UIMessage message, int messageIndex) {
var emitted = false;
for (var partIndex = 0; partIndex < message.parts().size(); partIndex++) {
var part = message.parts().get(partIndex);
+ if (part instanceof StepStartPart) {
+ emitted |= flushSegment(message, assistantContent, toolContent);
+ continue;
+ }
var context = new UIMessageConversionContext<>(messages, message, messageIndex,
part, partIndex);
if (isToolApprovalResponse(part)) {
- if (!assistantContent.isEmpty()) {
- emitted |= flushSegment(message, assistantContent, toolContent);
- }
assistantContent.addAll(convertToolCall((ToolPart) part));
assistantContent.add(convertToolApprovalRequest((ToolPart) part));
- emitted |= flushSegment(message, assistantContent, toolContent);
toolContent.add(convertToolApprovalResponse((ToolPart) part));
continue;
}
if (isTerminalToolPart(part)) {
- if (!toolContent.isEmpty()) {
- emitted |= flushSegment(message, assistantContent, toolContent);
- }
assistantContent.addAll(convertToolCall((ToolPart) part));
- emitted |= flushSegment(message, assistantContent, toolContent);
toolContent.addAll(convertToolOutput((ToolPart) part));
continue;
}
- var converted = convertPart(part, context);
- if (isToolResponsePart(part)) {
- toolContent.addAll(converted);
- } else {
- if (!toolContent.isEmpty()) {
- emitted |= flushSegment(message, assistantContent, toolContent);
- }
- assistantContent.addAll(converted);
- }
+ assistantContent.addAll(convertPart(part, context));
}
emitted |= flushSegment(message, assistantContent, toolContent);
if (!emitted) {
@@ -167,10 +155,6 @@ private boolean flushSegment(UIMessage message,
return emitted;
}
- private boolean isToolResponsePart(UIMessagePart part) {
- return isTerminalToolPart(part) || isToolApprovalResponse(part);
- }
-
private boolean isToolApprovalResponse(UIMessagePart part) {
return part instanceof ToolPart tool
&& (tool.state() == ToolPartState.APPROVAL_RESPONDED
diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessagePart.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessagePart.java
index fcd3721a..cd7cecd2 100644
--- a/api/src/main/java/run/halo/aifoundation/ui/UIMessagePart.java
+++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessagePart.java
@@ -7,8 +7,8 @@
* stream chunks such as tool input deltas, finish events, errors, and aborts are
* not represented as parts.
*/
-public sealed interface UIMessagePart permits TextPart, ReasoningPart, DataPart, ToolPart,
- SourceUrlPart, SourceDocumentPart, FilePart {
+public sealed interface UIMessagePart permits StepStartPart, TextPart, ReasoningPart, DataPart,
+ ToolPart, SourceUrlPart, SourceDocumentPart, FilePart {
/**
* Stable discriminator used by serializers and callers.
diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessagePartIdentity.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessagePartIdentity.java
index 459afd13..c3c43b75 100644
--- a/api/src/main/java/run/halo/aifoundation/ui/UIMessagePartIdentity.java
+++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessagePartIdentity.java
@@ -23,6 +23,7 @@ public record UIMessagePartIdentity(String type, String id) {
*/
public static UIMessagePartIdentity of(UIMessagePart part) {
return switch (part) {
+ case StepStartPart value -> new UIMessagePartIdentity(value.type(), value.type());
case TextPart value -> new UIMessagePartIdentity(value.type(), value.id());
case ReasoningPart value -> new UIMessagePartIdentity(value.type(), value.id());
case DataPart value -> new UIMessagePartIdentity(value.type(), value.id());
diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageParts.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageParts.java
index 08618e03..50682b36 100644
--- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageParts.java
+++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageParts.java
@@ -10,6 +10,15 @@ public final class UIMessageParts {
private UIMessageParts() {
}
+ /**
+ * Creates a persisted generation-step boundary marker.
+ *
+ * @return step-start marker
+ */
+ public static StepStartPart stepStart() {
+ return new StepStartPart();
+ }
+
/**
* Creates a persisted text part.
*
diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageTransportCodec.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageTransportCodec.java
index 86a9e6d5..482c7a0a 100644
--- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageTransportCodec.java
+++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageTransportCodec.java
@@ -286,6 +286,7 @@ public static UIMessagePart partFromMap(Map map) {
objectMap(map.get("providerMetadata")));
}
return switch (type) {
+ case UIMessageChunkType.STEP_START -> UIMessageParts.stepStart();
case UIMessageChunkType.TEXT -> UIMessageParts.text(stringValue(map.get("id")),
stringValue(map.get("text")));
case UIMessageChunkType.REASONING -> UIMessageParts.reasoning(
@@ -322,6 +323,8 @@ public static Map partToMap(UIMessagePart part) {
var map = new LinkedHashMap();
put(map, "type", part.type());
switch (part) {
+ case StepStartPart ignored -> {
+ }
case TextPart text -> {
put(map, "id", text.id());
put(map, "text", text.text());
diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageValidators.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageValidators.java
index a352bf06..cba5685e 100644
--- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageValidators.java
+++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageValidators.java
@@ -146,6 +146,12 @@ private void validatePart(UIMessage message, int messageIndex, UIMessagePart
"UI message part type must not be blank"));
}
switch (part) {
+ case StepStartPart ignored -> {
+ if (message.role() != UIMessageRole.ASSISTANT) {
+ issues.add(issue(message, part, null, "part.step-start.role.invalid",
+ "Step-start parts are only allowed in assistant messages"));
+ }
+ }
case TextPart text -> require(message, part, text.id(), "part.id.required",
"Text part id must not be blank");
case ReasoningPart reasoning -> require(message, part, reasoning.id(),
diff --git a/app/src/main/java/run/halo/aifoundation/service/capability/ModelCapabilityService.java b/app/src/main/java/run/halo/aifoundation/service/capability/ModelCapabilityService.java
index d93687ab..ffa9c0d5 100644
--- a/app/src/main/java/run/halo/aifoundation/service/capability/ModelCapabilityService.java
+++ b/app/src/main/java/run/halo/aifoundation/service/capability/ModelCapabilityService.java
@@ -32,7 +32,7 @@ public ModelCapabilities effectiveCapabilities(AiModel model,
var imageGeneration = copy(explicit == null ? null : explicit.getImageGeneration());
if (spec.getModelType() == ModelType.LANGUAGE) {
- language = applyLanguageDefaults(language, spec.getFeatures());
+ language = applyLanguageDefaults(language, spec.getFeatures(), providerType);
}
if (spec.getModelType() == ModelType.IMAGE_GENERATION
&& sources.getImageGeneration() != CapabilitySource.MANUAL) {
@@ -47,10 +47,18 @@ public ModelCapabilities effectiveCapabilities(AiModel model,
}
private LanguageCapability applyLanguageDefaults(LanguageCapability language,
- List features) {
+ List features, @Nullable AiProviderType providerType) {
var result = language;
var hasVision = features != null && features.contains(ModelFeature.VISION);
var hasAudioInput = features != null && features.contains(ModelFeature.AUDIO_INPUT);
+ if (providerType != null) {
+ result = result == null ? LanguageCapability.unknown() : result;
+ if (result.getReasoningHistory() == null) {
+ var providerOptions = providerType.languageModelProviderOptions();
+ result.setReasoningHistory(providerOptions != null
+ && providerOptions.reasoningHistorySupported());
+ }
+ }
if (hasVision) {
result = result == null ? LanguageCapability.unknown() : result;
if (result.getImageInput() == null) {
diff --git a/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelImpl.java b/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelImpl.java
index a99f0705..f4c4b0dc 100644
--- a/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelImpl.java
+++ b/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelImpl.java
@@ -100,6 +100,7 @@ public class LanguageModelImpl implements LanguageModel {
private final String providerType;
private final LanguageModelProviderOptions providerOptions;
private final run.halo.aifoundation.capability.ModelCapabilities modelCapabilities;
+ private final boolean reasoningHistorySupported;
private final LanguageModelRequestValidator requestValidator;
private final LanguageModelMessageMapper messageMapper;
private final GenerationMessageHistoryAssembler messageHistoryAssembler;
@@ -148,6 +149,7 @@ public class LanguageModelImpl implements LanguageModel {
this.providerType = composition.providerType();
this.providerOptions = composition.providerOptions();
this.modelCapabilities = composition.modelCapabilities();
+ this.reasoningHistorySupported = composition.reasoningHistorySupported();
this.requestValidator = composition.requestValidator();
this.messageMapper = composition.messageMapper();
this.messageHistoryAssembler = composition.messageHistoryAssembler();
@@ -211,7 +213,7 @@ public StreamTextResult streamText(GenerateTextRequest request) {
@Override
public LanguageModelCapabilities capabilities() {
- return LanguageModelCapabilities.of(providerOptions.reasoningHistorySupported(),
+ return LanguageModelCapabilities.of(reasoningHistorySupported,
modelCapabilities);
}
@@ -1276,7 +1278,7 @@ protected boolean supportsToolCalling() {
}
protected boolean supportsReasoningHistory() {
- return providerOptions.reasoningHistorySupported();
+ return reasoningHistorySupported;
}
private String toolCallingUnsupportedMessage() {
diff --git a/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelRuntimeComposition.java b/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelRuntimeComposition.java
index 3b3bfc01..5d01a245 100644
--- a/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelRuntimeComposition.java
+++ b/app/src/main/java/run/halo/aifoundation/service/language/LanguageModelRuntimeComposition.java
@@ -19,6 +19,7 @@ public record LanguageModelRuntimeComposition(
String providerType,
LanguageModelProviderOptions providerOptions,
ModelCapabilities modelCapabilities,
+ boolean reasoningHistorySupported,
LanguageModelRequestValidator requestValidator,
LanguageModelMessageMapper messageMapper,
GenerationMessageHistoryAssembler messageHistoryAssembler,
@@ -65,12 +66,14 @@ public static LanguageModelRuntimeComposition create(
var context = configuration.context();
var resolvedOptions = configuration.providerOptions();
var resolvedCapabilities = configuration.modelCapabilities();
+ var reasoningHistorySupported = reasoningHistorySupported(resolvedCapabilities,
+ resolvedOptions);
var requestValidator = new LanguageModelRequestValidator(context.providerType(),
- resolvedOptions.reasoningHistorySupported(), resolvedCapabilities, context.modelName(),
+ reasoningHistorySupported, resolvedCapabilities, context.modelName(),
context.providerName(), mediaResourcePolicy, capabilityMatcher);
var messageMapper = new LanguageModelMessageMapper(context.providerType());
var messageHistoryAssembler = new GenerationMessageHistoryAssembler(context.providerType(),
- resolvedOptions.reasoningHistorySupported(), messageMapper);
+ reasoningHistorySupported, messageMapper);
var chatOptionsBuilder = new LanguageModelChatOptionsBuilder(context.providerType(),
context.modelId(),
resolvedOptions, runtimeSupport::writeJson);
@@ -86,10 +89,19 @@ public static LanguageModelRuntimeComposition create(
runtimeSupport::withToolTimeout);
var toolStepCoordinator = new ToolStepCoordinator(toolExecutor);
return new LanguageModelRuntimeComposition(context.providerType(), resolvedOptions,
- resolvedCapabilities, requestValidator, messageMapper, messageHistoryAssembler,
- chatOptionsBuilder, responseMapper, reasoningExtractor, toolCallMapper,
- structuredOutputHandler, toolExecutor, toolStepCoordinator, new ToolApprovalResolver(),
- runtimeSupport);
+ resolvedCapabilities, reasoningHistorySupported, requestValidator, messageMapper,
+ messageHistoryAssembler, chatOptionsBuilder, responseMapper, reasoningExtractor,
+ toolCallMapper, structuredOutputHandler, toolExecutor, toolStepCoordinator,
+ new ToolApprovalResolver(), runtimeSupport);
+ }
+
+ private static boolean reasoningHistorySupported(ModelCapabilities capabilities,
+ LanguageModelProviderOptions providerOptions) {
+ var language = capabilities.getLanguage();
+ if (language != null && language.getReasoningHistory() != null) {
+ return language.getReasoningHistory();
+ }
+ return providerOptions.reasoningHistorySupported();
}
private static LanguageModelRuntimeConfiguration configuration(String providerType,
diff --git a/app/src/main/java/run/halo/aifoundation/service/language/mapping/LanguageModelRequestValidator.java b/app/src/main/java/run/halo/aifoundation/service/language/mapping/LanguageModelRequestValidator.java
index d863df9c..71d7dfbb 100644
--- a/app/src/main/java/run/halo/aifoundation/service/language/mapping/LanguageModelRequestValidator.java
+++ b/app/src/main/java/run/halo/aifoundation/service/language/mapping/LanguageModelRequestValidator.java
@@ -324,8 +324,8 @@ private void validateReasoningPart(ModelMessageRole role, ModelMessagePart part)
"reasoning content part is only supported for assistant messages");
}
if (!reasoningHistorySupported) {
- throw new IllegalArgumentException("reasoning content is not supported by provider type: "
- + providerType);
+ throw new IllegalArgumentException(
+ "assistant reasoning history is not supported by the resolved model");
}
if (!hasText(part.getText()) && (part.getProviderMetadata() == null
|| part.getProviderMetadata().isEmpty())) {
diff --git a/app/src/test/java/run/halo/aifoundation/provider/support/openai/OpenAiCompatibleModelsTest.java b/app/src/test/java/run/halo/aifoundation/provider/support/openai/OpenAiCompatibleModelsTest.java
index c5bb312f..e65f2116 100644
--- a/app/src/test/java/run/halo/aifoundation/provider/support/openai/OpenAiCompatibleModelsTest.java
+++ b/app/src/test/java/run/halo/aifoundation/provider/support/openai/OpenAiCompatibleModelsTest.java
@@ -55,6 +55,12 @@ void chatRequestBody_replaysReasoningContentForToolContinuation() {
var assistant = AssistantMessage.builder()
.content("")
.properties(Map.of("reasoningContent", "tool reasoning"))
+ .toolCalls(List.of(
+ new AssistantMessage.ToolCall("call-1", "function", "weather",
+ "{\"city\":\"Hangzhou\"}"),
+ new AssistantMessage.ToolCall("call-2", "function", "search",
+ "{\"query\":\"Halo\"}")
+ ))
.build();
var prompt = new Prompt(List.of(assistant), chatOptions());
@@ -66,6 +72,9 @@ void chatRequestBody_replaysReasoningContentForToolContinuation() {
assertThat(messages.getFirst())
.containsEntry("reasoning_content", "tool reasoning");
+ @SuppressWarnings("unchecked")
+ var toolCalls = (List