fix(channel): sync-path IM channels stop relaying pre-tool rehearsals verbatim

This commit is contained in:
matevip 2026-08-04 22:50:42 -04:00
parent 8a7a04ee1f
commit 32dd6e7911
5 changed files with 182 additions and 51 deletions

View File

@ -884,30 +884,47 @@ public class ChannelMessageRouter {
// and IM channels still need the text for the outgoing reply),
// segmentOnly narration excluded (issue #120).
AgentStreamAccumulator accumulator = newAccumulator();
// Narration lifecycle: relayed messages on this path are
// permanent (IM messages cannot be retracted), so per-stage
// narration publishes one behind through the shared tracker
// a pre-tool rehearsal (possibly a fabricated result table)
// is dropped once later content supersedes it instead of
// reaching the user verbatim.
final ProvisionalContentTracker narrationTracker =
new ProvisionalContentTracker(channelType);
agentService.chatStructuredStream(agentId, promptText, conversationId,
message.getSenderId(), chatOrigin)
.doOnNext(delta -> {
accumulator.accept(delta, conversationId);
if (!delta.isEvent() && delta.segmentOnly()) {
if (delta.isEvent()) {
if ("tool_call_completed".equals(delta.eventType())) {
narrationTracker.onToolObservation();
}
return;
}
if (delta.segmentOnly()) {
// Per-stage narration ("Let me look that up…"), emitted as
// one complete delta per agent loop iteration. Relay it
// immediately as its own outgoing message so the user sees
// progress mid-run.
// one complete delta per agent loop iteration, each becoming
// its own outgoing message so the user sees progress mid-run.
String narration = delta.content() != null ? delta.content().trim() : "";
if (relayNarration && !narration.isEmpty() && replyTarget != null) {
try {
adapter.renderAndSend(replyTarget, narration);
} catch (Exception sendErr) {
// A failed progress send must not abort the agent
// run the final reply still goes out below.
log.warn("[{}] Narration relay failed (non-fatal): {}",
channelType, sendErr.getMessage());
String publishable = narrationTracker.stageNarration(narration, delta.kind());
if (publishable != null) {
relayNarrationSafely(adapter, replyTarget, publishable);
}
}
}
})
.blockLast(Duration.ofMinutes(10));
String reply = accumulator.getContent();
// The last narration was held back until the answer was
// known: superseded dropped, otherwise it still goes out
// (before the final reply) unless it duplicates it.
String heldNarration = narrationTracker.settle(!reply.isBlank());
if (heldNarration != null && replyTarget != null
&& !heldNarration.equals(reply.trim())) {
relayNarrationSafely(adapter, replyTarget, heldNarration);
}
// The IM sync path bypasses FinalAnswerNode, so hallucinated
// /api/v1/files/generated/{id} URLs (LLM wrote a fake link
@ -1290,6 +1307,19 @@ public class ChannelMessageRouter {
return s == null || s.isBlank() ? null : s;
}
/**
* Send a progress narration as its own outgoing message. A failed send
* must not abort the agent run the final reply still goes out.
*/
private void relayNarrationSafely(ChannelAdapter adapter, String replyTarget, String narration) {
try {
adapter.renderAndSend(replyTarget, narration);
} catch (Exception sendErr) {
log.warn("[{}] Narration relay failed (non-fatal): {}",
adapter.getChannelType(), sendErr.getMessage());
}
}
/**
* 流式处理路径渠道无关
* <p>

View File

@ -48,38 +48,53 @@ public final class ProvisionalContentTracker {
/** Where the supersede happened — metric tag, one value per surface. */
private final String surface;
/** Tool observations completed so far this turn (caller-reported). */
private int observations;
/** Observation count at the time of the most recent staging. */
private int lastStageMark;
private String pendingText;
private boolean pendingProvisional;
/** Observation count when the pending narration was staged. */
private int pendingMark;
public ProvisionalContentTracker(String surface) {
this.surface = surface;
}
/** Report a completed tool observation (a {@code tool_call_completed} event). */
public void onToolObservation() {
observations++;
}
/**
* Stage a per-round narration. Returns the <em>previous</em> staged
* narration if the new arrival makes it publishable, or {@code null} when
* there is nothing to publish (no previous, or the previous was
* provisional and is now superseded by this later content).
* provisional and tool observations since its staging mean this later
* content supersedes it).
*
* @param kind producer-assigned kind; {@code null} for
* pre-tag producers
* @param observedSinceLast structural fallback used only when {@code kind}
* is null: whether a tool observation completed
* since the previous narration (the pre-tag
* online rule)
* @param kind producer-assigned kind; {@code null} for pre-tag producers,
* in which case a narration counts as provisional when no
* observation completed since the previous staging (the
* pre-tag online rule)
*/
public String stageNarration(String text, ContentKind kind, boolean observedSinceLast) {
public String stageNarration(String text, ContentKind kind) {
boolean observedSinceLast = observations > lastStageMark;
boolean provisional = kind != null
? kind == ContentKind.PRE_TOOL_NARRATION
: !observedSinceLast;
String previous = pendingText;
boolean previousProvisional = pendingProvisional;
int previousMark = pendingMark;
pendingText = text;
pendingProvisional = provisional;
pendingMark = observations;
lastStageMark = observations;
if (previous == null) {
return null;
}
if (previousProvisional) {
if (previousProvisional && observations > previousMark) {
recordSuperseded(previous);
return null;
}
@ -89,7 +104,8 @@ public final class ProvisionalContentTracker {
/**
* Resolve the staged narration at turn end. Returns the text to publish,
* or {@code null} when nothing remains (no staged narration, or it was
* provisional and the turn produced final content that replaces it).
* provisional, tools ran after it, and the turn produced final content
* that replaces it).
*
* @param hasFinalContent whether the turn produced a final answer with
* one, a provisional narration is superseded; with
@ -99,12 +115,14 @@ public final class ProvisionalContentTracker {
public String settle(boolean hasFinalContent) {
String text = pendingText;
boolean provisional = pendingProvisional;
int mark = pendingMark;
pendingText = null;
pendingProvisional = false;
pendingMark = 0;
if (text == null) {
return null;
}
if (provisional && hasFinalContent) {
if (provisional && hasFinalContent && observations > mark) {
recordSuperseded(text);
return null;
}

View File

@ -1532,14 +1532,12 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
// the final answer always lands in the newest bubble.
AtomicReference<WeComReplyContext> liveCtx = new AtomicReference<>(initialCtx);
ProvisionalContentTracker tracker = new ProvisionalContentTracker("wecom");
AtomicInteger toolResults = new AtomicInteger();
final int[] lastNarrationToolMark = {0};
final long[] lastFlushAt = {0L};
stream.doOnNext(delta -> {
boolean flushNow = false;
if (delta.isEvent()) {
if ("tool_call_completed".equals(delta.eventType())) {
toolResults.incrementAndGet();
tracker.onToolObservation();
}
flushNow = progress.onEvent(delta.eventType(), delta.eventData());
if (standaloneToolMessages) {
@ -1563,11 +1561,8 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
// means this one was written with real output in hand).
String narration = delta.content() != null ? delta.content().trim() : "";
if (!narration.isEmpty()) {
int mark = toolResults.get();
boolean observedSinceLast = mark > lastNarrationToolMark[0];
lastNarrationToolMark[0] = mark;
progress.onNarration(narration);
String publishable = tracker.stageNarration(narration, delta.kind(), observedSinceLast);
String publishable = tracker.stageNarration(narration, delta.kind());
if (publishable != null) {
WeComReplyContext ctx = liveCtx.get();
if (replyContexts.get(replyTarget) == ctx) {

View File

@ -25,11 +25,14 @@ import static org.mockito.Mockito.*;
/**
* Sync-path narration routing for non-streaming IM adapters.
*
* <p>Per-iteration narration deltas ({@code segmentOnly}) must be relayed as
* standalone messages the moment they arrive and stay out of the accumulated
* final reply otherwise multiple iterations glue into a wall of text and
* the persisted assistant message pollutes the next turn's LLM history with
* unanswered stated intents (issue #120).
* <p>Per-iteration narration deltas ({@code segmentOnly}) are relayed as
* standalone messages and stay out of the accumulated final reply otherwise
* multiple iterations glue into a wall of text and the persisted assistant
* message pollutes the next turn's LLM history with unanswered stated intents
* (issue #120). Publishing lags one narration behind through the shared
* provisional-content tracker: messages on this path are permanent, so a
* pre-tool rehearsal (possibly a fabricated result table) must be dropped
* once later content supersedes it instead of reaching the user verbatim.
*/
class ChannelMessageRouterNarrationTest {
@ -100,6 +103,61 @@ class ChannelMessageRouterNarrationTest {
f.verifyNoProcessingError();
}
@Test
@DisplayName("kind-tagged pre-tool rehearsal is dropped once a grounded answer exists")
void preToolRehearsalDroppedForGroundedAnswer() throws Exception {
Fixture f = new Fixture();
String rehearsal = "环境监测结果:温度 29.0°C湿度 63.0%。";
f.streamReturns(
StreamDelta.segmentOnly(rehearsal, null, vip.mate.agent.ContentKind.PRE_TOOL_NARRATION),
StreamDelta.event("tool_call_completed",
java.util.Map.of("toolCallId", "t1", "toolName", "envQuery",
"result", "data={}", "success", true)),
StreamDelta.persistOnly("接口返回为空,所有会议室均无环境数据。", null));
f.process("各会议室的环境情况");
verify(f.adapter, never()).renderAndSend("reply-1", rehearsal);
verify(f.adapter).renderAndSend("reply-1", "接口返回为空,所有会议室均无环境数据。");
f.verifyPersistedAssistantContent("接口返回为空,所有会议室均无环境数据。");
f.verifyNoProcessingError();
}
@Test
@DisplayName("kind-tagged pre-tool narration still goes out when the turn produced no answer")
void preToolNarrationKeptWhenNoAnswer() throws Exception {
Fixture f = new Fixture();
f.streamReturns(
StreamDelta.segmentOnly("我先调用工具查询状态:", null,
vip.mate.agent.ContentKind.PRE_TOOL_NARRATION),
StreamDelta.event("tool_call_completed",
java.util.Map.of("toolCallId", "t1", "toolName", "q",
"result", "x", "success", true)));
f.process("查一下状态");
verify(f.adapter).renderAndSend("reply-1", "我先调用工具查询状态:");
}
@Test
@DisplayName("untagged narration is dropped when a tool ran after it and later content followed")
void untaggedNarrationDroppedAfterObservation() throws Exception {
Fixture f = new Fixture();
f.streamReturns(
StreamDelta.segmentOnly("我先查一下天气", null),
StreamDelta.event("tool_call_completed",
java.util.Map.of("toolCallId", "t1", "toolName", "weather",
"result", "", "success", true)),
StreamDelta.segmentOnly("查到了,整理结果", null),
StreamDelta.persistOnly("今天晴。", null));
f.process("帮我查天气");
verify(f.adapter, never()).renderAndSend("reply-1", "我先查一下天气");
verify(f.adapter).renderAndSend("reply-1", "查到了,整理结果");
verify(f.adapter).renderAndSend("reply-1", "今天晴。");
}
@Test
@DisplayName("persistOnly and plain content deltas still accumulate into one reply")
void nonNarrationDeltasStillAccumulate() throws Exception {

View File

@ -29,9 +29,10 @@ class ProvisionalContentTrackerTest {
void preToolNarrationSupersededByNextNarration() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
assertNull(t.stageNarration("先查询会议室数据。", ContentKind.PRE_TOOL_NARRATION, false),
assertNull(t.stageNarration("先查询会议室数据。", ContentKind.PRE_TOOL_NARRATION),
"first narration has no predecessor to publish");
String publishable = t.stageNarration("第一间空闲,继续。", ContentKind.GROUNDED_NARRATION, true);
t.onToolObservation();
String publishable = t.stageNarration("第一间空闲,继续。", ContentKind.GROUNDED_NARRATION);
assertNull(publishable, "provisional predecessor is superseded, not published");
assertEquals("第一间空闲,继续。", t.settle(true),
@ -43,8 +44,10 @@ class ProvisionalContentTrackerTest {
void groundedNarrationPublishesOnNext() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
t.stageNarration("时间拿到了,再查会议室:", ContentKind.GROUNDED_NARRATION, true);
String publishable = t.stageNarration("会议室也查完了。", ContentKind.GROUNDED_NARRATION, true);
t.onToolObservation();
t.stageNarration("时间拿到了,再查会议室:", ContentKind.GROUNDED_NARRATION);
t.onToolObservation();
String publishable = t.stageNarration("会议室也查完了。", ContentKind.GROUNDED_NARRATION);
assertEquals("时间拿到了,再查会议室:", publishable);
}
@ -53,7 +56,8 @@ class ProvisionalContentTrackerTest {
void preToolNarrationSupersededByFinalAnswer() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
t.stageNarration("环境监测结果:温度 29.0°C。", ContentKind.PRE_TOOL_NARRATION, false);
t.stageNarration("环境监测结果:温度 29.0°C。", ContentKind.PRE_TOOL_NARRATION);
t.onToolObservation();
assertNull(t.settle(true), "fabricated rehearsal must not survive once a grounded answer exists");
}
@ -62,16 +66,28 @@ class ProvisionalContentTrackerTest {
void preToolNarrationCommitsWithoutAnswer() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
t.stageNarration("我先调用工具查询状态:", ContentKind.PRE_TOOL_NARRATION, false);
t.stageNarration("我先调用工具查询状态:", ContentKind.PRE_TOOL_NARRATION);
t.onToolObservation();
assertEquals("我先调用工具查询状态:", t.settle(false),
"with no replacement content the narration is everything the user gets");
}
@Test
@DisplayName("pre-tool narration with no tool run after it survives settle — nothing replaced it")
void preToolNarrationWithoutToolsAfterSurvives() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
t.stageNarration("我先调用工具:", ContentKind.PRE_TOOL_NARRATION);
assertEquals("我先调用工具:", t.settle(true),
"supersede requires an observation after staging — e.g. all tools denied leaves nothing to defer to");
}
@Test
@DisplayName("grounded narration always settles publishable")
void groundedNarrationSettles() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
t.stageNarration("查完了,结果如下。", ContentKind.GROUNDED_NARRATION, true);
t.onToolObservation();
t.stageNarration("查完了,结果如下。", ContentKind.GROUNDED_NARRATION);
assertEquals("查完了,结果如下。", t.settle(true));
}
@ -79,7 +95,7 @@ class ProvisionalContentTrackerTest {
@DisplayName("settle clears state — second settle finds nothing")
void settleClearsState() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
t.stageNarration("x", ContentKind.GROUNDED_NARRATION, true);
t.stageNarration("x", ContentKind.GROUNDED_NARRATION);
t.settle(true);
assertNull(t.settle(true));
}
@ -87,26 +103,40 @@ class ProvisionalContentTrackerTest {
@Test
@DisplayName("null kind falls back to the observation-counter rule")
void nullKindFallsBackToCounter() {
// No observation before staging provisional; a tool ran after superseded.
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
// No observation since turn start provisional (pre-tag online rule).
t.stageNarration("我先查一下:", null, false);
t.stageNarration("我先查一下:", null);
t.onToolObservation();
assertNull(t.settle(true), "untagged pre-tool narration still dropped for a grounded answer");
// Observation completed since last narration grounded.
// Observation completed before staging grounded, publishes.
ProvisionalContentTracker t2 = new ProvisionalContentTracker("test");
t2.stageNarration("拿到结果了。", null, true);
t2.onToolObservation();
t2.stageNarration("拿到结果了。", null);
assertEquals("拿到结果了。", t2.settle(true));
}
@Test
@DisplayName("untagged narrations with no tool activity between them all publish — legacy relay preserved")
void untaggedNoToolStreamKeepsRelay() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
t.stageNarration("我先查一下天气", null);
assertEquals("我先查一下天气", t.stageNarration("再帮你汇总结果", null),
"no observation between the two — the predecessor was not replaced by anything grounded");
assertEquals("再帮你汇总结果", t.settle(true));
}
@Test
@DisplayName("producer kind outranks the counter signal when both are present")
void kindOutranksCounter() {
// The counter says an observation happened, but the producer knows the
// text was emitted before any observation of this turn (the two can
// disagree when multiple narrations land between two completions).
// The counter says an observation preceded the narration, but the
// producer knows the text was emitted before any observation of this
// turn (the two can disagree when multiple narrations land between
// two completions). Kind wins.
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
t.stageNarration("预演内容", ContentKind.PRE_TOOL_NARRATION, true);
t.onToolObservation();
t.stageNarration("预演内容", ContentKind.PRE_TOOL_NARRATION);
t.onToolObservation();
assertNull(t.settle(true), "kind is authoritative — counter signal ignored when tagged");
}