mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 21:59:07 +08:00
fix(webui): split merge-into-tail compaction so reply renders as its own bubble (#29824)
The compressor has a "double-collision" fallback path: when the chosen ``summary_role`` collides with the first tail message AND the flipped role would collide with the last head message, it can't emit a standalone summary turn (consecutive same-role messages break Anthropic and friends). It instead prepends the summary + end-of-summary marker to the first tail message's content via ``_merge_summary_into_tail``. With the matching anchor from the previous commit, that first tail message is now usually the user's previously-visible assistant reply — so the persisted assistant turn ends up shaped as ``[CONTEXT COMPACTION ...] ... --- END OF CONTEXT SUMMARY --- ... THE ACTUAL REPLY``. Without splitting it, the session viewer renders one big "Context handoff" bubble and the reply text is buried inside the metadata blob — which is exactly the "can't see the last reply" experience #29824 reports, just one layer deeper. Added ``splitCompactionContent`` that detects the merge marker (kept in sync with ``--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---`` in ``agent/context_compressor.py``) and ``MessageBubble`` now recurses on the two halves: the prefix half renders as the muted "Context handoff" row, the remainder half renders with the original assistant styling. Pure (non-merged) summary messages hit the no-remainder branch and still render as a single "Context handoff" row, preserving the original behaviour.
This commit is contained in:
@@ -157,22 +157,50 @@ function ToolCallBlock({
|
||||
// detect them here and downgrade them to a muted, clearly-labelled
|
||||
// "Context handoff" row.
|
||||
//
|
||||
// Keep these prefixes in sync with ``SUMMARY_PREFIX`` and
|
||||
// ``LEGACY_SUMMARY_PREFIX`` in ``agent/context_compressor.py``.
|
||||
// Keep these prefixes (and the END marker below) in sync with
|
||||
// ``SUMMARY_PREFIX`` / ``LEGACY_SUMMARY_PREFIX`` and the
|
||||
// merge-into-tail marker in ``agent/context_compressor.py``.
|
||||
const COMPACTION_PREFIXES = [
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY]",
|
||||
"[CONTEXT COMPACTION - REFERENCE ONLY]",
|
||||
"[CONTEXT SUMMARY]:",
|
||||
] as const;
|
||||
|
||||
function isCompactionMessage(msg: SessionMessage): boolean {
|
||||
if (msg.role !== "user" && msg.role !== "assistant") return false;
|
||||
const content = msg.content;
|
||||
if (typeof content !== "string") return false;
|
||||
const head = content.trimStart();
|
||||
return COMPACTION_PREFIXES.some((p) => head.startsWith(p));
|
||||
// Marker the compressor inserts between a merged summary and the
|
||||
// original tail message content. When the summary role would collide
|
||||
// with both head and tail roles (e.g. head ends with ``user`` and tail
|
||||
// starts with ``assistant``), the compressor merges the summary as a
|
||||
// prefix on the first tail message instead of inserting a standalone
|
||||
// row. We split on this marker so the WebUI still shows the original
|
||||
// assistant reply as its own readable bubble — otherwise the merged
|
||||
// row reads as a single opaque "Context compaction" block and the
|
||||
// user can't see the reply (#29824).
|
||||
const COMPACTION_END_MARKER =
|
||||
"--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---";
|
||||
|
||||
interface CompactionSplit {
|
||||
/** Summary text (header + body, without the end marker). */
|
||||
summary: string;
|
||||
/** Original message content that came after the end marker. */
|
||||
remainder: string;
|
||||
}
|
||||
|
||||
function splitCompactionContent(content: string): CompactionSplit | null {
|
||||
const head = content.trimStart();
|
||||
if (!COMPACTION_PREFIXES.some((p) => head.startsWith(p))) return null;
|
||||
const markerIdx = content.indexOf(COMPACTION_END_MARKER);
|
||||
if (markerIdx < 0) {
|
||||
return { summary: content, remainder: "" };
|
||||
}
|
||||
return {
|
||||
summary: content.slice(0, markerIdx),
|
||||
remainder: content
|
||||
.slice(markerIdx + COMPACTION_END_MARKER.length)
|
||||
.replace(/^\s+/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function MessageBubble({
|
||||
msg,
|
||||
highlight,
|
||||
@@ -216,7 +244,42 @@ function MessageBubble({
|
||||
},
|
||||
};
|
||||
|
||||
const isCompaction = isCompactionMessage(msg);
|
||||
// When a compaction handoff is merged into the front of the first
|
||||
// tail message (the compressor's double-collision path —
|
||||
// ``_merge_summary_into_tail`` in ``agent/context_compressor.py``),
|
||||
// the message we received is ``[CONTEXT COMPACTION ...] + END_MARKER
|
||||
// + <original assistant reply>``. We split it back into two visual
|
||||
// rows here so the operator's actual answer survives as a readable
|
||||
// bubble next to the (clearly-labelled) handoff metadata (#29824).
|
||||
const compactionSplit =
|
||||
typeof msg.content === "string"
|
||||
? splitCompactionContent(msg.content)
|
||||
: null;
|
||||
|
||||
if (compactionSplit && compactionSplit.remainder) {
|
||||
return (
|
||||
<>
|
||||
<MessageBubble
|
||||
msg={{ ...msg, content: compactionSplit.summary }}
|
||||
highlight={highlight}
|
||||
/>
|
||||
<MessageBubble
|
||||
msg={{
|
||||
...msg,
|
||||
content: compactionSplit.remainder,
|
||||
// The remainder is the original assistant reply that the
|
||||
// compressor pre-pended the summary to — render with the
|
||||
// normal assistant styling, NOT the muted handoff style.
|
||||
// ``isCompactionMessage`` returns false on this stripped
|
||||
// content because it no longer starts with the prefix.
|
||||
}}
|
||||
highlight={highlight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isCompaction = compactionSplit !== null;
|
||||
const style = isCompaction
|
||||
? ROLE_STYLES.compaction
|
||||
: ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
|
||||
|
||||
Reference in New Issue
Block a user