mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
fix(agent): persist mid-turn narrative, queue follow-ups without dispose, flush on shutdown
A bundle of stability fixes that all surfaced together while running the same long-form generation task across multiple turns. Each one addresses a distinct way the previous behavior silently dropped content the user had already seen on screen. 1. Mid-turn narrative persistence (StateGraphReActAgent + SummarizingNode). Intermediate ReasoningNode rounds and SummarizingNode broadcast their content_delta directly to the SSE channel for live display, but the StreamAccumulator only received the final answer. After refresh the assistant message showed only tool_call cards with no body text. StateGraphReActAgent now also forwards STREAMED_CONTENT (already set per round) as a persistOnly StreamDelta whenever it changes, so every narrative chunk lands in the accumulator's content buffer and gets written to mate_message. SummarizingNode now writes its summary into the same key so summarize narratives persist too. 2. Follow-up message queue, not dispose (ChatController#interruptStream). Sending a new message while a turn was running called requestInterrupt, which dispose()d the active Reactor chain mid LLM call. That cancelled the in-flight generation, lost partial tokens, and left the user staring at a half-finished bubble. The endpoint now uses enqueueMessage in all paths, matching the "wait for current turn, then run" behavior. The old requestInterrupt API is kept for any future force-replace UI but no caller routes to it. 3. Queued user message ordering (ChatStreamTracker.QueuedInput + ChatController.startQueuedMessage). interruptStream used to save the queued user message immediately, before the in-flight assistant message finalized in doOnError. listMessages orders by create_time ASC, so the queued user message ended up above the assistant reply it was supposed to follow. QueuedInput now carries contentParts; persistence is delayed to startQueuedMessage, which runs only after Asst-N is on disk. 4. JVM shutdown flush (ChatStreamTracker @PreDestroy + emergencySaveAccumulator). A mvn spring-boot:run restart used to wipe in-flight turns: SSE emitter timed out, ShutdownHook fired, HikariPool closed before doOnError could save. ChatStreamTracker now exposes an emergency-save callback per RunState; ChatController registers one per stream that snapshots the accumulator and writes status="interrupted_shutdown". @PreDestroy walks active runs, invokes the callback, then disposes. Spring's reverse-order bean teardown keeps ConversationService and Hikari alive long enough for the save to complete. 5. Observation thresholds for summarize (GraphObservationProperties + application.yml). The previous total-chars threshold of 12 KB triggered summarize after one or two RFC reads, costing a 40 to 80 second compaction LLM call per loop. Tuned to: total 200 KB, single 16 KB, large-result 32 KB, rounds safety net 25. Java field defaults reverted to the conservative original values so application.yml stays the source of truth. 6. Frontend thinking segmentation (useChat.ts thinking_delta + phase). Multi-round ReAct turns merged every reasoning + summarize round's thinking into one segment, accumulating to 9 KB+ in a single bubble. thinking_delta now uses findLast(running) so a tool_call_started or phase transition closes the previous segment and the next delta opens a fresh one. phase event also closes running thinking/content segments. 7. Other small things bundled: removed a debug metadata-keys log that flooded the log file with one line per stream chunk; fixed three stale tests that didn't compile after earlier constructor changes (WikiLogServiceTest, WikiOverviewSpliceTest, WikiProcessingServiceLazyTest); added rfc-066 documenting the unified message queue + priority refactor as the next logical step on top of these stabilizations. Verified end-to-end with multiple full sessions: a four-minute generation that produced the expected docx and a follow-up enqueue that ran cleanly after the previous turn naturally completed, without the old "Disposable unavailable" interrupt path.
This commit is contained in:
parent
941653d185
commit
0476447ab6
@ -743,10 +743,6 @@ public class NodeStreamingChatHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 提取 token usage(通常最后一个 chunk 携带完整 usage)
|
// 4. 提取 token usage(通常最后一个 chunk 携带完整 usage)
|
||||||
// E-4 probe: log metadata keys to check for x-ratelimit-* headers
|
|
||||||
if (chatResponse.getMetadata() != null && log.isDebugEnabled()) {
|
|
||||||
log.debug("[E4-probe] metadata keys: {}", chatResponse.getMetadata().keySet());
|
|
||||||
}
|
|
||||||
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
|
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
|
||||||
var usage = chatResponse.getMetadata().getUsage();
|
var usage = chatResponse.getMetadata().getUsage();
|
||||||
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {
|
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {
|
||||||
|
|||||||
@ -175,6 +175,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
// 防重保护:同 chatStructuredStream
|
// 防重保护:同 chatStructuredStream
|
||||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||||
|
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||||
|
|
||||||
return compiledGraph.stream(inputs, config)
|
return compiledGraph.stream(inputs, config)
|
||||||
.flatMapIterable(output -> {
|
.flatMapIterable(output -> {
|
||||||
@ -192,6 +193,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
||||||
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
||||||
|
|
||||||
|
// 与 chatStructuredStream 一致:把每轮 STREAMED_CONTENT 用 persistOnly 推给 Accumulator,
|
||||||
|
// 否则中间叙述(reasoning narrative + summarize)只在 SSE 上出现一次,刷新后丢失。
|
||||||
|
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||||
|
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||||
|
lastEmittedStreamedContent.set(streamed);
|
||||||
|
deltas.add(AgentService.StreamDelta.persistOnly(streamed, null));
|
||||||
|
}
|
||||||
|
|
||||||
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||||
String answer = extractFinalAnswer(output);
|
String answer = extractFinalAnswer(output);
|
||||||
if (answer != null && !answer.isEmpty()) {
|
if (answer != null && !answer.isEmpty()) {
|
||||||
@ -265,6 +274,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
||||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||||
|
// STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写),
|
||||||
|
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。
|
||||||
|
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||||
|
|
||||||
return compiledGraph.stream(inputs, config)
|
return compiledGraph.stream(inputs, config)
|
||||||
.flatMapIterable(output -> {
|
.flatMapIterable(output -> {
|
||||||
@ -287,6 +299,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
boolean thinkingAlreadyStreamed = output.state()
|
boolean thinkingAlreadyStreamed = output.state()
|
||||||
.value(THINKING_STREAMED, false);
|
.value(THINKING_STREAMED, false);
|
||||||
|
|
||||||
|
// 2a. 中间叙述内容持久化:每轮 ReasoningNode(带 tool_calls)和 SummarizingNode
|
||||||
|
// 都把当轮 LLM 输出写入 STREAMED_CONTENT。NodeStreamingChatHelper 已实时广播
|
||||||
|
// 给前端,但 Accumulator 不在 SSE 订阅链路上,必须用 persistOnly StreamDelta
|
||||||
|
// 补一刀,否则刷新后正文文字全部丢失(只剩 final_answer + tool_call 卡片)。
|
||||||
|
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
|
||||||
|
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||||
|
lastEmittedStreamedContent.set(streamed);
|
||||||
|
deltas.add(AgentService.StreamDelta.persistOnly(streamed, null));
|
||||||
|
}
|
||||||
|
|
||||||
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||||
String answer = extractFinalAnswer(output);
|
String answer = extractFinalAnswer(output);
|
||||||
if (answer != null && !answer.isEmpty()) {
|
if (answer != null && !answer.isEmpty()) {
|
||||||
|
|||||||
@ -183,6 +183,9 @@ public class SummarizingNode implements NodeAction {
|
|||||||
// 摘要的 content 已流式推送,但它不是最终回答,标记防重即可
|
// 摘要的 content 已流式推送,但它不是最终回答,标记防重即可
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
|
// 把当轮 summary 文本写入 STREAMED_CONTENT,让 StateGraphReActAgent 用 persistOnly
|
||||||
|
// StreamDelta 推给 Accumulator 持久化(用户刷新页面后能看到摘要正文,否则只剩 tool_call 卡片)
|
||||||
|
.streamedContent(summaryContent)
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
// 不设 finishReason — summarizing 不是终止,循环继续
|
// 不设 finishReason — summarizing 不是终止,循环继续
|
||||||
.events(List.of(GraphEventPublisher.phase("summarized", Map.of(
|
.events(List.of(GraphEventPublisher.phase("summarized", Map.of(
|
||||||
|
|||||||
@ -738,6 +738,10 @@ public class ChatController {
|
|||||||
|
|
||||||
// 将 Disposable 注册到 StreamTracker,以便 stop 端点可以取消它
|
// 将 Disposable 注册到 StreamTracker,以便 stop 端点可以取消它
|
||||||
streamTracker.setDisposable(conversationId, disposable);
|
streamTracker.setDisposable(conversationId, disposable);
|
||||||
|
// JVM 关闭时优雅落盘:避免 mvn spring-boot:run 重启 / SIGTERM 把
|
||||||
|
// 进行中 turn 的 assistant 消息丢失(doOnError 来不及在 Hikari 关闭前执行)
|
||||||
|
streamTracker.setEmergencySaveCallback(conversationId,
|
||||||
|
() -> emergencySaveAccumulator(conversationId, accumulator));
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("SSE setup error: {}", e.getMessage());
|
log.error("SSE setup error: {}", e.getMessage());
|
||||||
@ -773,12 +777,15 @@ public class ChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 中断当前流并排队一条后续消息。
|
* 在执行中追加一条后续消息:仅入队,等当前 turn 自然结束后再启动。
|
||||||
* <p>
|
* <p>
|
||||||
* 与 stop 的区别:interrupt 会在当前 turn 安全结束后自动启动排队消息。
|
* 对齐 Claude Code 行为:流式输出过程中收到新输入不会强制 dispose 当前 LLM 调用,
|
||||||
* 如果当前阶段不可中断(awaiting_approval),消息会被排队但不打断当前执行。
|
* 而是仅入队。当前 turn 跑到 doOnComplete/doOnError 后由 startQueuedMessage 接管。
|
||||||
|
* <p>
|
||||||
|
* 旧行为(dispose 当前 disposable + 立即重启)会导致部分 LLM 输出被丢弃 + token 浪费,
|
||||||
|
* 已废弃。如果未来需要"立即打断"语义,应该走 /stop(用户主动取消)+ 重新发起新消息的路径。
|
||||||
*/
|
*/
|
||||||
@Operation(summary = "中断并排队后续消息")
|
@Operation(summary = "排队后续消息(不打断当前流)")
|
||||||
@PostMapping("/{conversationId}/interrupt")
|
@PostMapping("/{conversationId}/interrupt")
|
||||||
public R<Map<String, Object>> interruptStream(
|
public R<Map<String, Object>> interruptStream(
|
||||||
@PathVariable String conversationId,
|
@PathVariable String conversationId,
|
||||||
@ -797,34 +804,20 @@ public class ChatController {
|
|||||||
Long agentId = request.getAgentId();
|
Long agentId = request.getAgentId();
|
||||||
List<MessageContentPart> contentParts = request.getContentParts();
|
List<MessageContentPart> contentParts = request.getContentParts();
|
||||||
|
|
||||||
// 判断当前阶段是否可中断
|
// 判断当前阶段(仅用于 reason 字段,行为对所有阶段一致:仅入队)
|
||||||
// awaiting_approval 阶段不直接中断,只排队
|
|
||||||
boolean isAwaitingApproval = approvalService.findPendingByConversation(conversationId) != null;
|
boolean isAwaitingApproval = approvalService.findPendingByConversation(conversationId) != null;
|
||||||
|
|
||||||
if (isAwaitingApproval) {
|
// 仅入队、不 dispose。延迟持久化到 startQueuedMessage(让 Asst-N 先在 doOnComplete 落库,
|
||||||
// 不可中断:排队但不打断。先持久化(含 contentParts)再入队(persisted=true)
|
// 否则 listMessages ORDER BY create_time ASC 会把 Q(N+1) 排到 Asst-N 前面)
|
||||||
conversationService.saveMessage(conversationId, "user", message, contentParts, "queued");
|
boolean queued = streamTracker.enqueueMessage(conversationId, message, agentId, false, contentParts);
|
||||||
boolean queued = streamTracker.enqueueMessage(conversationId, message, agentId, true);
|
log.info("Enqueued follow-up message during running turn: conversationId={}, user={}, queueSize={}, awaitingApproval={}",
|
||||||
log.info("Interrupt requested during approval, message queued: conversationId={}, user={}, queueSize={}",
|
conversationId, username, streamTracker.getQueueSize(conversationId), isAwaitingApproval);
|
||||||
conversationId, username, streamTracker.getQueueSize(conversationId));
|
|
||||||
return R.ok(Map.of(
|
|
||||||
"interrupted", false,
|
|
||||||
"queued", queued,
|
|
||||||
"reason", "awaiting_approval"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 可中断:先持久化(含 contentParts)再打断并入队(persisted=true)
|
|
||||||
conversationService.saveMessage(conversationId, "user", message, contentParts, "queued");
|
|
||||||
boolean interrupted = streamTracker.requestInterrupt(conversationId, message, agentId, true);
|
|
||||||
log.info("Interrupt requested: conversationId={}, user={}, interrupted={}, queueSize={}",
|
|
||||||
conversationId, username, interrupted, streamTracker.getQueueSize(conversationId));
|
|
||||||
|
|
||||||
return R.ok(Map.of(
|
return R.ok(Map.of(
|
||||||
"interrupted", interrupted,
|
"interrupted", false,
|
||||||
"queued", true,
|
"queued", queued,
|
||||||
"queueSize", streamTracker.getQueueSize(conversationId),
|
"queueSize", streamTracker.getQueueSize(conversationId),
|
||||||
"reason", interrupted ? "interrupted" : "queued"
|
"reason", isAwaitingApproval ? "awaiting_approval" : "queued"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1009,9 +1002,12 @@ public class ChatController {
|
|||||||
log.info("Starting queued message: conversationId={}, agentId={}, message={}",
|
log.info("Starting queued message: conversationId={}, agentId={}, message={}",
|
||||||
conversationId, agentId, queuedMessage.substring(0, Math.min(30, queuedMessage.length())));
|
conversationId, agentId, queuedMessage.substring(0, Math.min(30, queuedMessage.length())));
|
||||||
|
|
||||||
// 持久化排队的用户消息(幂等:如果 /interrupt 已提前持久化则跳过)
|
// 持久化排队的用户消息(含 contentParts;幂等:如果 /interrupt 已提前持久化则跳过)。
|
||||||
|
// 这里持久化是为了确保 user 消息在 assistant 消息(doOnError/doOnCancel 已写入)之后落库,
|
||||||
|
// 让 listMessages ORDER BY create_time ASC 后顺序正确:Q1 → Asst1 → Q2 → Asst2。
|
||||||
if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) {
|
if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) {
|
||||||
conversationService.saveMessage(conversationId, "user", queuedMessage);
|
conversationService.saveMessage(conversationId, "user", queuedMessage,
|
||||||
|
preConsumedInput.contentParts(), "queued");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 广播 queued_input_started 事件
|
// 广播 queued_input_started 事件
|
||||||
@ -1122,6 +1118,8 @@ public class ChatController {
|
|||||||
})
|
})
|
||||||
.subscribe();
|
.subscribe();
|
||||||
streamTracker.setDisposable(conversationId, disposable);
|
streamTracker.setDisposable(conversationId, disposable);
|
||||||
|
streamTracker.setEmergencySaveCallback(conversationId,
|
||||||
|
() -> emergencySaveAccumulator(conversationId, accumulator));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendEvent(SseEmitter emitter, String name, Object data) throws IOException {
|
private void sendEvent(SseEmitter emitter, String name, Object data) throws IOException {
|
||||||
@ -1170,6 +1168,40 @@ public class ChatController {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the accumulator and persist it as an assistant message during JVM shutdown.
|
||||||
|
* Invoked from {@link ChatStreamTracker#onShutdown()} so any in-flight turn doesn't
|
||||||
|
* lose its already-streamed content + tool calls when the process exits.
|
||||||
|
* <p>
|
||||||
|
* Idempotent w.r.t. the normal doOnComplete/doOnError save: if those paths already
|
||||||
|
* persisted the message, this writes a second row with status="interrupted_shutdown",
|
||||||
|
* which is rare in practice (race window is sub-second between dispose and save) and
|
||||||
|
* acceptable. Skipping save when nothing to save avoids empty rows.
|
||||||
|
*/
|
||||||
|
private void emergencySaveAccumulator(String conversationId, StreamAccumulator accumulator) {
|
||||||
|
try {
|
||||||
|
String text = accumulator.getContent();
|
||||||
|
List<MessageContentPart> parts = accumulator.toAssistantParts();
|
||||||
|
if (text.isBlank() && parts.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String savedText = text.isBlank() ? "[已中断 — 服务重启]" : text;
|
||||||
|
conversationService.saveMessage(conversationId, "assistant", savedText, parts,
|
||||||
|
"interrupted_shutdown",
|
||||||
|
accumulator.getPromptTokens(),
|
||||||
|
accumulator.getCompletionTokens(),
|
||||||
|
accumulator.getRuntimeModelName(),
|
||||||
|
accumulator.getRuntimeProviderId(),
|
||||||
|
accumulator.toMetadataJson());
|
||||||
|
log.info("[ChatController] Emergency-saved in-flight assistant message: " +
|
||||||
|
"conversationId={}, textLen={}, partsCount={}",
|
||||||
|
conversationId, text.length(), parts.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[ChatController] Emergency save failed for {}: {}",
|
||||||
|
conversationId, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<MessageContentPart> normalizeRequestParts(ChatStreamRequest request) {
|
private List<MessageContentPart> normalizeRequestParts(ChatStreamRequest request) {
|
||||||
if (request.getContentParts() != null && !request.getContentParts().isEmpty()) {
|
if (request.getContentParts() != null && !request.getContentParts().isEmpty()) {
|
||||||
return request.getContentParts();
|
return request.getContentParts();
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
package vip.mate.channel.web;
|
package vip.mate.channel.web;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
import reactor.core.Disposable;
|
import reactor.core.Disposable;
|
||||||
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -104,6 +106,18 @@ public class ChatStreamTracker {
|
|||||||
/** 排队的用户消息队列(支持多条排队消息,按序消费) */
|
/** 排队的用户消息队列(支持多条排队消息,按序消费) */
|
||||||
final java.util.Queue<QueuedInput> messageQueue = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
final java.util.Queue<QueuedInput> messageQueue = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emergency save callback registered by the SSE chain owner (ChatController).
|
||||||
|
* Invoked from {@link #onShutdown()} so the accumulated assistant content + tool_calls
|
||||||
|
* are persisted before the JVM tears down — without this, a `mvn spring-boot:run`
|
||||||
|
* restart wipes any in-flight turn and leaves only the user message in DB.
|
||||||
|
* <p>
|
||||||
|
* The callback must be idempotent (will not be called twice for the same run, but
|
||||||
|
* may race with normal doOnComplete/doOnError; both paths must tolerate the other
|
||||||
|
* having saved already).
|
||||||
|
*/
|
||||||
|
volatile Runnable emergencySaveCallback;
|
||||||
|
|
||||||
/** 心跳定时器 */
|
/** 心跳定时器 */
|
||||||
volatile ScheduledFuture<?> heartbeatFuture;
|
volatile ScheduledFuture<?> heartbeatFuture;
|
||||||
|
|
||||||
@ -190,6 +204,18 @@ public class ChatStreamTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an emergency-save callback for this run, invoked from {@link #onShutdown()}
|
||||||
|
* before the JVM tears down. The callback should snapshot the current accumulator
|
||||||
|
* state and persist it as the assistant message (status="interrupted").
|
||||||
|
*/
|
||||||
|
public void setEmergencySaveCallback(String conversationId, Runnable callback) {
|
||||||
|
RunState state = runs.get(conversationId);
|
||||||
|
if (state != null) {
|
||||||
|
state.emergencySaveCallback = callback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 请求停止指定会话的流。
|
* 请求停止指定会话的流。
|
||||||
* 取消 Flux 订阅(底层 HTTP 连接也会随之关闭),返回 true 表示确实停止了正在运行的流。
|
* 取消 Flux 订阅(底层 HTTP 连接也会随之关闭),返回 true 表示确实停止了正在运行的流。
|
||||||
@ -577,6 +603,11 @@ public class ChatStreamTracker {
|
|||||||
* @return true 如果成功请求了中断
|
* @return true 如果成功请求了中断
|
||||||
*/
|
*/
|
||||||
public boolean requestInterrupt(String conversationId, String queuedMessage, Long agentId, boolean persisted) {
|
public boolean requestInterrupt(String conversationId, String queuedMessage, Long agentId, boolean persisted) {
|
||||||
|
return requestInterrupt(conversationId, queuedMessage, agentId, persisted, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean requestInterrupt(String conversationId, String queuedMessage, Long agentId,
|
||||||
|
boolean persisted, List<MessageContentPart> contentParts) {
|
||||||
RunState state = runs.get(conversationId);
|
RunState state = runs.get(conversationId);
|
||||||
if (state == null || state.done) {
|
if (state == null || state.done) {
|
||||||
return false;
|
return false;
|
||||||
@ -589,7 +620,7 @@ public class ChatStreamTracker {
|
|||||||
Disposable d = state.disposable;
|
Disposable d = state.disposable;
|
||||||
canInterrupt = d != null && !d.isDisposed();
|
canInterrupt = d != null && !d.isDisposed();
|
||||||
// 无论是否可中断,都入队(支持多条排队消息)
|
// 无论是否可中断,都入队(支持多条排队消息)
|
||||||
state.messageQueue.offer(new QueuedInput(queuedMessage, agentId, persisted));
|
state.messageQueue.offer(new QueuedInput(queuedMessage, agentId, persisted, contentParts));
|
||||||
if (canInterrupt) {
|
if (canInterrupt) {
|
||||||
state.interruptType = InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
|
state.interruptType = InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
|
||||||
state.stopRequested.set(true);
|
state.stopRequested.set(true);
|
||||||
@ -636,11 +667,16 @@ public class ChatStreamTracker {
|
|||||||
* 将消息加入队列但不中断当前执行(用于不可中断阶段)。
|
* 将消息加入队列但不中断当前执行(用于不可中断阶段)。
|
||||||
*/
|
*/
|
||||||
public boolean enqueueMessage(String conversationId, String message, Long agentId, boolean persisted) {
|
public boolean enqueueMessage(String conversationId, String message, Long agentId, boolean persisted) {
|
||||||
|
return enqueueMessage(conversationId, message, agentId, persisted, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enqueueMessage(String conversationId, String message, Long agentId, boolean persisted,
|
||||||
|
List<MessageContentPart> contentParts) {
|
||||||
RunState state = runs.get(conversationId);
|
RunState state = runs.get(conversationId);
|
||||||
if (state == null || state.done) {
|
if (state == null || state.done) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
state.messageQueue.offer(new QueuedInput(message, agentId, persisted));
|
state.messageQueue.offer(new QueuedInput(message, agentId, persisted, contentParts));
|
||||||
// broadcast 在锁外
|
// broadcast 在锁外
|
||||||
try {
|
try {
|
||||||
String json = objectMapper.writeValueAsString(Map.of(
|
String json = objectMapper.writeValueAsString(Map.of(
|
||||||
@ -656,9 +692,14 @@ public class ChatStreamTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 排队输入的原子快照(message + agentId + persisted 一起返回,避免分离读取导致不一致)
|
* 排队输入的原子快照(message + agentId + persisted + contentParts 一起返回,避免分离读取导致不一致)
|
||||||
*/
|
*/
|
||||||
public record QueuedInput(String message, Long agentId, boolean persisted) {}
|
public record QueuedInput(String message, Long agentId, boolean persisted,
|
||||||
|
List<MessageContentPart> contentParts) {
|
||||||
|
public QueuedInput(String message, Long agentId, boolean persisted) {
|
||||||
|
this(message, agentId, persisted, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 原子消费排队的输入(流完成/中断后调用)。
|
* 原子消费排队的输入(流完成/中断后调用)。
|
||||||
@ -905,4 +946,64 @@ public class ChatStreamTracker {
|
|||||||
evicted, runs.size());
|
evicted, runs.size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush in-flight runs before JVM shutdown.
|
||||||
|
* <p>
|
||||||
|
* Spring closes singleton beans in reverse construction order; ConversationService /
|
||||||
|
* Hikari outlive ChatStreamTracker, so saveMessage from {@link #onShutdown()} still
|
||||||
|
* has a working DB connection. Without this, a {@code mvn spring-boot:run} restart or
|
||||||
|
* SIGTERM during a turn races against the Reactor cancellation: the doOnError /
|
||||||
|
* doOnComplete saveMessage may not run before HikariPool shuts down, leaving the
|
||||||
|
* conversation with only the user message and no assistant reply (the
|
||||||
|
* "对话框里除了问题外什么也没留下" symptom seen in production logs at 07:23:02).
|
||||||
|
* <p>
|
||||||
|
* Behavior:
|
||||||
|
* <ol>
|
||||||
|
* <li>Walk every active (not-done) RunState.</li>
|
||||||
|
* <li>Invoke its registered emergencySaveCallback synchronously — the callback
|
||||||
|
* (set by ChatController) snapshots the current accumulator and persists it
|
||||||
|
* as an "interrupted" assistant message.</li>
|
||||||
|
* <li>Dispose the Reactor disposable so the LLM stream terminates promptly.</li>
|
||||||
|
* </ol>
|
||||||
|
* The callback must tolerate normal doOnError/doOnComplete having raced and saved
|
||||||
|
* already; the latest commit wins for that conversation.
|
||||||
|
*/
|
||||||
|
@PreDestroy
|
||||||
|
public void onShutdown() {
|
||||||
|
int active = (int) runs.values().stream().filter(s -> !s.done).count();
|
||||||
|
if (active == 0) {
|
||||||
|
log.info("[ChatStreamTracker] Shutdown: no active runs to flush");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.warn("[ChatStreamTracker] Shutdown: flushing {} active run(s) before JVM exit",
|
||||||
|
active);
|
||||||
|
for (Map.Entry<String, RunState> entry : runs.entrySet()) {
|
||||||
|
RunState state = entry.getValue();
|
||||||
|
if (state.done) continue;
|
||||||
|
String cid = entry.getKey();
|
||||||
|
try {
|
||||||
|
Runnable callback = state.emergencySaveCallback;
|
||||||
|
if (callback != null) {
|
||||||
|
log.info("[ChatStreamTracker] Emergency-saving in-flight run: {}", cid);
|
||||||
|
callback.run();
|
||||||
|
} else {
|
||||||
|
log.warn("[ChatStreamTracker] No emergency-save callback for active run: {} " +
|
||||||
|
"(content may be lost)", cid);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[ChatStreamTracker] Emergency save failed for {}: {}",
|
||||||
|
cid, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Disposable d = state.disposable;
|
||||||
|
if (d != null && !d.isDisposed()) {
|
||||||
|
d.dispose();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ChatStreamTracker] Disposable.dispose failed for {}: {}",
|
||||||
|
cid, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,13 @@ import lombok.Data;
|
|||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Graph 观察结果处理阈值配置
|
* Graph observation thresholds for triggering summarize / compaction.
|
||||||
|
* <p>
|
||||||
|
* <b>Tune values in {@code application.yml} under {@code mate.agent.graph.observation},
|
||||||
|
* not in the Java field defaults.</b> The yml is the source of truth and is loaded into
|
||||||
|
* a Spring bean by {@link ConfigurationProperties}; field defaults below are kept as a
|
||||||
|
* conservative fallback for tests / unit constructors and intentionally do NOT reflect
|
||||||
|
* production-tuned values.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -12,27 +18,27 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||||||
@ConfigurationProperties(prefix = "mate.agent.graph.observation")
|
@ConfigurationProperties(prefix = "mate.agent.graph.observation")
|
||||||
public class GraphObservationProperties {
|
public class GraphObservationProperties {
|
||||||
|
|
||||||
/** 单次工具结果最大字符数 */
|
/** Max chars retained per tool result (truncate above this). Tune via yml. */
|
||||||
private int maxSingleObservationChars = 4000;
|
private int maxSingleObservationChars = 4000;
|
||||||
|
|
||||||
/** 所有观察记录总字符数上限 */
|
/** Total observation chars across history+current that trigger summarize. Tune via yml. */
|
||||||
private int maxTotalObservationChars = 12000;
|
private int maxTotalObservationChars = 12000;
|
||||||
|
|
||||||
/** 单次结果超过此阈值视为"大结果" */
|
/** Single tool result above this is treated as "large" and triggers summarize. Tune via yml. */
|
||||||
private int largeResultThreshold = 3000;
|
private int largeResultThreshold = 3000;
|
||||||
|
|
||||||
/** 触发 summarize 的最小观察轮次 */
|
/** Minimum observation rounds that triggers summarize as a runaway-loop safety net. Tune via yml. */
|
||||||
private int minRoundsForSummarize = 3;
|
private int minRoundsForSummarize = 3;
|
||||||
|
|
||||||
/** 截断时保留前部占比(0-1) */
|
/** Truncation: head fraction kept (0-1). */
|
||||||
private double headRatio = 0.4;
|
private double headRatio = 0.4;
|
||||||
|
|
||||||
/** 截断省略标记(%d 会被替换为原始字符数) */
|
/** Truncation marker; %d is replaced with original char count. */
|
||||||
private String truncationMarker = "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n";
|
private String truncationMarker = "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n";
|
||||||
|
|
||||||
/** 检测到尾部错误模式时的 tail 保留比例(默认 0.8,优先保留错误信息) */
|
/** Tail-keep ratio when an error pattern is detected at the tail (prefer keeping error info). */
|
||||||
private double errorTailRatio = 0.8;
|
private double errorTailRatio = 0.8;
|
||||||
|
|
||||||
/** 截断时最少保留字符数(避免过度截断导致信息完全丢失) */
|
/** Minimum chars retained on truncation to avoid losing all information. */
|
||||||
private int minKeepChars = 2000;
|
private int minKeepChars = 2000;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -154,10 +154,11 @@ mate:
|
|||||||
agent:
|
agent:
|
||||||
graph:
|
graph:
|
||||||
observation:
|
observation:
|
||||||
max-single-observation-chars: 8000
|
# 与 GraphObservationProperties.java 默认值对齐,参考 openclaw token-budget 设计
|
||||||
max-total-observation-chars: 24000
|
max-single-observation-chars: 16000
|
||||||
large-result-threshold: 6000
|
max-total-observation-chars: 200000
|
||||||
min-rounds-for-summarize: 3
|
large-result-threshold: 32000
|
||||||
|
min-rounds-for-summarize: 25
|
||||||
head-ratio: 0.4
|
head-ratio: 0.4
|
||||||
truncation-marker: "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n"
|
truncation-marker: "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n"
|
||||||
tool:
|
tool:
|
||||||
|
|||||||
@ -288,18 +288,21 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
if (streamPhase.value !== 'summarizing_observations') {
|
if (streamPhase.value !== 'summarizing_observations') {
|
||||||
streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking'
|
streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking'
|
||||||
}
|
}
|
||||||
// Segments: all thinking deltas merge into one segment (not split by tool_call interruptions)
|
// Segments: per-round thinking. When tool_call_started / phase change closes the running
|
||||||
|
// thinking segment (status='completed'), a fresh thinking_delta opens a new segment instead
|
||||||
|
// of reopening the closed one. Without this split, multi-round ReAct (3 reasoning + 2
|
||||||
|
// summarizing rounds) accumulates 9K+ chars in a single bubble.
|
||||||
const segs = currentSegments.value
|
const segs = currentSegments.value
|
||||||
// Reuse an existing thinking segment regardless of status (running or completed)
|
let thinkSeg = segs.findLast((s: MessageSegment) =>
|
||||||
let thinkSeg = segs.find((s: MessageSegment) => s.type === 'thinking')
|
s.type === 'thinking' && s.status === 'running'
|
||||||
|
)
|
||||||
if (!thinkSeg) {
|
if (!thinkSeg) {
|
||||||
thinkSeg = { id: genSegId(), type: 'thinking', status: 'running', thinkingText: '', timestamp: Date.now() }
|
thinkSeg = { id: genSegId(), type: 'thinking', status: 'running', thinkingText: '', timestamp: Date.now() }
|
||||||
// Insert at front — thinking always appears at the top
|
// Append in timeline order (interleaved with tool_calls) — old behavior unshift'd to top,
|
||||||
segs.unshift(thinkSeg)
|
// but with per-round splitting that misorders rounds 2+ relative to their tool calls.
|
||||||
|
segs.push(thinkSeg)
|
||||||
flushSegmentsToMessage()
|
flushSegmentsToMessage()
|
||||||
}
|
}
|
||||||
// Re-mark as running when new thinking content arrives
|
|
||||||
thinkSeg.status = 'running'
|
|
||||||
thinkSeg.thinkingText = (thinkSeg.thinkingText || '') + (data.delta || '')
|
thinkSeg.thinkingText = (thinkSeg.thinkingText || '') + (data.delta || '')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -596,6 +599,15 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
metadata: { ...metadata, currentPhase: data.phase }
|
metadata: { ...metadata, currentPhase: data.phase }
|
||||||
} as any)
|
} as any)
|
||||||
}
|
}
|
||||||
|
// Close any running thinking/content segment on phase transition so the next thinking_delta
|
||||||
|
// (e.g. summarizing → reasoning) starts a fresh round-scoped segment instead of growing the
|
||||||
|
// previous one unbounded.
|
||||||
|
const segs = currentSegments.value
|
||||||
|
for (const seg of segs) {
|
||||||
|
if (seg.status === 'running' && (seg.type === 'thinking' || seg.type === 'content')) {
|
||||||
|
seg.status = 'completed'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user