diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 742af1453807..f144b3f67a63 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -445,6 +445,45 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: return repairs +def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int: + """Run :func:`repair_message_sequence` and keep the SessionDB flush + cursor consistent with the compacted list (#44837). + + ``repair_message_sequence`` merges/drops messages in place, shrinking + the list. ``_last_flushed_db_idx`` (the DB-write cursor) indexes into + that list, so after compaction it can point past the new end — the + turn-end flush would then skip the assistant/tool chain entirely — or + past unflushed messages shifted to lower indexes. + + Repair preserves object identity for surviving messages, so counting + the survivors from the previously-flushed prefix gives the exact new + cursor even when messages are dropped/merged at indexes *before* the + cursor — a plain ``min()`` clamp would silently skip that many + unflushed rows. Falls back to the clamp when no prefix snapshot is + available. + + Returns the number of repairs made (same as ``repair_message_sequence``). + """ + pre_repair_flushed_ids = None + flush_cursor = getattr(agent, "_last_flushed_db_idx", None) + if isinstance(flush_cursor, int) and flush_cursor > 0: + pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]} + + repairs = repair_message_sequence(agent, messages) + + if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"): + if pre_repair_flushed_ids is not None: + agent._last_flushed_db_idx = sum( + 1 for m in messages if id(m) in pre_repair_flushed_ids + ) + else: + agent._last_flushed_db_idx = min( + agent._last_flushed_db_idx, len(messages) + ) + + return repairs + + def strip_think_blocks(agent, content: str) -> str: """Remove reasoning/thinking blocks from content, returning only visible text. diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 4611616085f8..16db1bedc30f 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -69,6 +69,31 @@ SUMMARY_PREFIX = ( ) LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:" +# Metadata key added to context compression summary messages so that frontends +# (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user +# messages and filter or render them appropriately without content-prefix +# heuristics. See https://github.com/NousResearch/hermes-agent/issues/38389 +# +# Underscore-prefixed ON PURPOSE: the wire sanitizers +# (agent/transports/chat_completions.py convert_messages and the summary-path +# mirror in agent/chat_completion_helpers.py) strip every top-level message +# key starting with "_" before the request leaves the process. Strict +# OpenAI-compatible gateways (Fireworks, Mistral, Moonshot/Kimi, opencode-go) +# reject payloads carrying unknown keys with "Extra inputs are not permitted", +# poisoning every subsequent request in the session — a bare key like +# "is_compressed_summary" would reach the wire and trip exactly that. +COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary" + +# Appended to every standalone summary message (and to the merged-into-tail +# prefix) so the model has an unambiguous "summary ends here" boundary. +# Without it, weak models read the verbatim "## Active Task" quote as fresh +# user input (#11475, #14521) or regurgitate an assistant-role summary as +# their own output (#33256). +_SUMMARY_END_MARKER = ( + "--- END OF CONTEXT SUMMARY — " + "respond to the message below, not the summary above ---" +) + # Handoff prefixes that shipped in earlier releases. A summary persisted under # one of these can be inherited into a resumed lineage (#35344); when it is # re-normalized on re-compaction we must strip the OLD prefix too, otherwise the @@ -146,6 +171,11 @@ _FALLBACK_TURN_MAX_CHARS = 700 _AUTO_FOCUS_MAX_TURNS = 3 _AUTO_FOCUS_TURN_MAX_CHARS = 260 _AUTO_FOCUS_MAX_CHARS = 700 +# Keep a short run of recent messages verbatim even when the token budget is +# already exhausted. The public ``protect_last_n`` default is intentionally +# high for small/light tails, but using all 20 as a hard floor here would bring +# back the old large-tool-output case where nothing can be compacted. +_MAX_TAIL_MESSAGE_FLOOR = 8 _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+") @@ -1616,7 +1646,13 @@ This compaction should PRIORITISE preserving all information related to the focu text = (summary or "").strip() for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES): if text.startswith(prefix): - return text[len(prefix):].lstrip() + text = text[len(prefix):].lstrip() + break + # Strip the trailing end marker too — a rehydrated handoff body that + # keeps it would leak the boundary directive into the iterative-update + # summarizer prompt (and the marker is re-appended on insertion anyway). + if text.endswith(_SUMMARY_END_MARKER): + text = text[: -len(_SUMMARY_END_MARKER)].rstrip() return text @classmethod @@ -1632,6 +1668,19 @@ This compaction should PRIORITISE preserving all information related to the focu return True return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES) + @staticmethod + def _has_compressed_summary_metadata(message: Any) -> bool: + """Return True if *message* carries the compressed-summary flag. + + Callers (frontends, CLI, gateway) can use this to distinguish context + compaction summaries from real assistant or user messages without + relying on content-prefix heuristics. The flag is in-process only — + the wire sanitizers strip underscore-prefixed keys before API calls. + """ + if not isinstance(message, dict): + return False + return bool(message.get(COMPRESSED_SUMMARY_METADATA_KEY)) + @classmethod def _derive_auto_focus_topic( cls, @@ -1817,6 +1866,105 @@ This compaction should PRIORITISE preserving all information related to the focu return i return -1 + def _find_last_assistant_message_idx( + self, messages: List[Dict[str, Any]], head_end: int + ) -> int: + """Return the index of the last user-visible assistant reply at or + after *head_end*, or -1. + + A "user-visible reply" is an assistant message with non-empty + textual content — i.e. one that the WebUI / TUI / SessionsPage + rendered as a bubble the operator could read. We deliberately + skip assistant messages that contain only ``tool_calls`` (and + no text), because those render as small "calling tool X" + indicators and aren't what the reporter means by "the output + of the last message you sent" (#29824). + + Falling back to the most recent assistant message of ANY kind + only kicks in when no content-bearing assistant message exists + in the compressible region — typically a fresh session that + just started a multi-step tool sequence with no prior reply + to anchor. In that case the agent fix is a no-op and the + existing user-message anchor carries the load. + """ + last_any = -1 + for i in range(len(messages) - 1, head_end - 1, -1): + msg = messages[i] + if msg.get("role") != "assistant": + continue + if last_any < 0: + last_any = i + content = msg.get("content") + if isinstance(content, str) and content.strip(): + return i + if isinstance(content, list): + # Multimodal / Anthropic-style content: look for any + # text block with non-empty text. + for part in content: + if isinstance(part, dict): + text = part.get("text") or part.get("content") + if isinstance(text, str) and text.strip(): + return i + return last_any + + def _ensure_last_assistant_message_in_tail( + self, + messages: List[Dict[str, Any]], + cut_idx: int, + head_end: int, + ) -> int: + """Guarantee the most recent assistant message is in the protected tail. + + WebUI / TUI / SessionsPage bug (#29824). Without this anchor, + ``_find_tail_cut_by_tokens`` can leave the user's most recent + visible assistant response inside the compressed middle region — + especially when the conversation has a single oversized tool + result or a long stretch of tool-call/result pairs after the + last assistant reply. The summariser then rolls that reply up + into the single ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block + persisted as ``role="user"`` or ``role="assistant"``. From the + operator's perspective the WebUI session viewer + (``web/src/pages/SessionsPage.tsx``) and the TUI chat panel + both suddenly show the opaque "Context compaction" block in the + slot where they were just reading the assistant's actual reply: + + User: "i cant see the output of the last message you + sent, i did see it previously, however now see + 'context compaction'" + + Mirror of ``_ensure_last_user_message_in_tail`` but anchors on + the last assistant-role message. Re-runs the tool-group + alignment so we don't split a ``tool_call`` / ``tool_result`` + group that immediately precedes the anchored message — orphaned + tool messages would otherwise be removed by + ``_sanitize_tool_pairs`` and trigger the same data-loss symptom + we're trying to prevent. + """ + last_asst_idx = self._find_last_assistant_message_idx(messages, head_end) + if last_asst_idx < 0: + # No assistant message in the compressible region — nothing + # to anchor (single-turn pre-reply state, etc.). + return cut_idx + if last_asst_idx >= cut_idx: + # Already in the tail — the token-budget walk did the right + # thing on its own. + return cut_idx + # Pull cut_idx back to the assistant message, then re-align so + # we don't split a tool group that immediately precedes it + # (e.g. an ``assistant(tool_calls)`` → ``tool(result)`` → + # ``assistant(final reply)`` sequence would otherwise leave the + # ``tool`` orphan when cut lands at the final reply). + new_cut = self._align_boundary_backward(messages, last_asst_idx) + if not self.quiet_mode: + logger.debug( + "Anchoring tail cut to last assistant message at index %d " + "(was %d, aligned to %d) to keep the previously-visible " + "reply out of the compaction summary (#29824)", + last_asst_idx, cut_idx, new_cut, + ) + # Safety: never go back into the head region. + return max(new_cut, head_end + 1) + def _ensure_last_user_message_in_tail( self, messages: List[Dict[str, Any]], @@ -1875,11 +2023,12 @@ This compaction should PRIORITISE preserving all information related to the focu derived from ``summary_target_ratio * context_length``, so it scales automatically with the model's context window. - Token budget is the primary criterion. A hard minimum of 3 messages - is always protected, but the budget is allowed to exceed by up to - 1.5x to avoid cutting inside an oversized message (tool output, file - read, etc.). If even the minimum 3 messages exceed 1.5x the budget - the cut is placed right after the head so compression still runs. + Token budget is the primary criterion. A bounded message-count floor + keeps a short run of recent turns verbatim even when the budget is + exhausted, but the budget is allowed to exceed by up to 1.5x to avoid + cutting inside an oversized message (tool output, file read, etc.). If + even that floor exceeds 1.5x the budget, the cut is placed right after + the head so compression still runs. Never cuts inside a tool_call/result group. Always ensures the most recent user message is in the tail (see ``_ensure_last_user_message_in_tail``). @@ -1887,8 +2036,19 @@ This compaction should PRIORITISE preserving all information related to the focu if token_budget is None: token_budget = self.tail_token_budget n = len(messages) - # Hard minimum: always keep at least 3 messages in the tail - min_tail = min(3, n - head_end - 1) if n - head_end > 1 else 0 + # Hard minimum: always keep a bounded recent-message floor in the tail. + # ``protect_last_n`` remains a minimum up to the cap; the cap avoids + # preserving a whole run of bulky tool outputs on every compaction. + available_tail = max(0, n - head_end - 1) + min_tail_floor = max(3, min(self.protect_last_n, _MAX_TAIL_MESSAGE_FLOOR)) + # Leave at least two non-head messages available to summarize on short + # transcripts; otherwise compression can replace a tiny middle with a + # summary and save no messages at all. + compressible_tail_cap = max(3, available_tail - 2) + min_tail = ( + min(min_tail_floor, compressible_tail_cap, available_tail) + if available_tail > 1 else 0 + ) soft_ceiling = int(token_budget * 1.5) accumulated = 0 cut_idx = n # start from beyond the end @@ -1960,6 +2120,13 @@ This compaction should PRIORITISE preserving all information related to the focu # active task is never lost to compression (fixes #10896). cut_idx = self._ensure_last_user_message_in_tail(messages, cut_idx, head_end) + # Ensure the most recent assistant message is always in the tail + # so the previously-visible reply isn't silently rolled into the + # ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block (fixes #29824). + # Each anchor only walks ``cut_idx`` backward, so chaining them is + # monotonic — the tail can only grow, never shrink. + cut_idx = self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end) + return max(cut_idx, head_end + 1) # ------------------------------------------------------------------ @@ -2193,32 +2360,33 @@ This compaction should PRIORITISE preserving all information related to the focu # When the summary lands as a standalone role="user" message, # weak models read the verbatim "## Active Task" quote of a past - # user request as fresh input (#11475, #14521). Append the explicit - # end marker — the same one used in the merge-into-tail path — so - # the model has a clear "summary above, not new input" signal. - if not _merge_summary_into_tail and summary_role == "user": - summary = ( - summary - + "\n\n--- END OF CONTEXT SUMMARY — " - "respond to the message below, not the summary above ---" - ) + # user request as fresh input (#11475, #14521). + # When it lands as role="assistant", models may regurgitate the + # summary text as their own output (#33256). In both cases, append + # the explicit end marker so the model has a clear "summary ends + # here, respond to the message below" signal. + if not _merge_summary_into_tail: + summary = summary + "\n\n" + _SUMMARY_END_MARKER if not _merge_summary_into_tail: - compressed.append({"role": summary_role, "content": summary}) + compressed.append({ + "role": summary_role, + "content": summary, + COMPRESSED_SUMMARY_METADATA_KEY: True, + }) for i in range(compress_end, n_messages): msg = messages[i].copy() if _merge_summary_into_tail and i == compress_end: - merged_prefix = ( - summary - + "\n\n--- END OF CONTEXT SUMMARY — " - "respond to the message below, not the summary above ---\n\n" - ) + merged_prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n" msg["content"] = _append_text_to_content( msg.get("content"), merged_prefix, prepend=True, ) + # Mark the merged message so frontends can identify it as + # containing a compression summary prefix. + msg[COMPRESSED_SUMMARY_METADATA_KEY] = True _merge_summary_into_tail = False compressed.append(msg) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index bcd84a373bbe..05d19772d9ca 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -595,7 +595,11 @@ def run_conversation( # landed after an orphan tool result). Most providers return # empty content on malformed sequences, which would otherwise # retrigger the empty-retry loop indefinitely. - repaired_seq = agent._repair_message_sequence(messages) + # repair_message_sequence_with_cursor also recomputes the SessionDB + # flush cursor (_last_flushed_db_idx) when repair compacts the list, + # so the turn-end flush doesn't skip the assistant/tool chain (#44837). + from agent.agent_runtime_helpers import repair_message_sequence_with_cursor + repaired_seq = repair_message_sequence_with_cursor(agent, messages) if repaired_seq > 0: request_logger.info( "Repaired %s message-alternation violations before request (session=%s)", diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 26b435c7c9e1..725039620f9e 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -53,6 +53,7 @@ import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from import type { ChatBarState } from './composer/types' import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions' import { useFileDropZone } from './hooks/use-file-drop-zone' +import { ScrollToBottomButton } from './scroll-to-bottom-button' import { SessionActionsMenu } from './sidebar/session-actions-menu' import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading' @@ -397,6 +398,7 @@ export function ChatView({ )} + {showChatBar && } diff --git a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx new file mode 100644 index 000000000000..b5d947ac18cf --- /dev/null +++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx @@ -0,0 +1,58 @@ +import { useStore } from '@nanostores/react' +import { useRef } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { cn } from '@/lib/utils' +import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll' + +/** + * Floating "jump to bottom" control. Sits centered just above the composer, + * clearing the out-of-flow status stack via the same measured-height CSS vars + * the thread's bottom clearance uses (`--composer-measured-height` + + * `--status-stack-measured-height`), so it never overlaps the queue / subagent + * / background cards. Visible only while the user has scrolled meaningfully + * away from the bottom; clicking re-arms sticky-bottom and pins the viewport. + * + * Enter/exit motion lives in styles.css under `.thread-jump-button` — a + * directional scale (contract in from 1.1, contract out to 0.9) keyed off + * `data-state`. `idle` (never-shown) stays silent so it can't flash on mount; + * `in`/`out` only swap once it has actually appeared. + */ +export function ScrollToBottomButton() { + const { t } = useI18n() + const visible = useStore($threadJumpButtonVisible) + const hasShownRef = useRef(false) + + if (visible) { + hasShownRef.current = true + } + + const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle' + + return ( + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx index 0b3a5c824afe..c42084138493 100644 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -14,13 +14,21 @@ import { import { setMutableRef } from '@/lib/mutable-ref' import { cn } from '@/lib/utils' -import { setThreadScrolledUp } from '@/store/thread-scroll' +import { + onScrollToBottomRequest, + resetThreadScroll, + setThreadJumpButtonVisible, + setThreadScrolledUp +} from '@/store/thread-scroll' import { MessageRenderBoundary } from './message-render-boundary' const ESTIMATED_ITEM_HEIGHT = 220 const OVERSCAN = 4 const AT_BOTTOM_THRESHOLD = 4 +// Reveal the floating jump button only once scrolled meaningfully away — above +// AT_BOTTOM_THRESHOLD so a sub-pixel settle never flashes it. +const JUMP_BUTTON_THRESHOLD = 10 type ThreadMessageComponents = ComponentProps['components'] @@ -309,7 +317,7 @@ function useThreadScrollAnchor({ }) }, [groupCount, pinToBottom, stickyBottomRef, virtualizer]) - useEffect(() => () => setThreadScrolledUp(false), []) + useEffect(() => () => resetThreadScroll(), []) // Track at-bottom state, dim composer when scrolled up, disarm on user // scroll/wheel/touch. @@ -325,6 +333,13 @@ function useThreadScrollAnchor({ programmaticScrollPendingRef.current = 0 } + // Dim the composer the instant we leave the bottom; reveal the jump button + // only once scrolled meaningfully away. + const publishScrollDistance = (dist: number) => { + setThreadScrolledUp(dist > AT_BOTTOM_THRESHOLD) + setThreadJumpButtonVisible(dist > JUMP_BUTTON_THRESHOLD) + } + const onScroll = () => { const top = el.scrollTop @@ -342,22 +357,19 @@ function useThreadScrollAnchor({ lastClientHeightRef.current = el.clientHeight // Always re-arm — sticky-bottom should hold through clamp races. setMutableRef(stickyBottomRef, true) - const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD - setThreadScrolledUp(!atBottom) + publishScrollDistance(el.scrollHeight - (top + el.clientHeight)) return } - // Disarm only when `scrollTop` decreases while both content height and - // viewport height are stable. A bare `top < lastTopRef.current` check is - // unsafe: virtualizer measurement, streaming markdown, composer resizing, - // window resizing, and toolbar/status updates can all move scrollTop as a - // layout side effect. Wheel-up and touchmove still disarm immediately via - // their own listeners below, so real user intent remains covered. + // Disarm on ANY upward movement (even 1px), but only while content + + // viewport height are stable — virtualizer measurement, streaming + // markdown, and composer/window resize all shift scrollTop as a layout + // side effect. Wheel-up and touchmove disarm immediately too (below). const heightGrew = el.scrollHeight > lastHeightRef.current const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1 - if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) { + if (!heightGrew && !clientHeightChanged && top < lastTopRef.current) { setMutableRef(stickyBottomRef, false) } @@ -365,13 +377,14 @@ function useThreadScrollAnchor({ lastHeightRef.current = el.scrollHeight lastClientHeightRef.current = el.clientHeight - const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD + const distFromBottom = el.scrollHeight - (top + el.clientHeight) - if (atBottom) { + // Re-arm follow only once genuinely back at the bottom. + if (distFromBottom <= AT_BOTTOM_THRESHOLD) { setMutableRef(stickyBottomRef, true) } - setThreadScrolledUp(!atBottom) + publishScrollDistance(distFromBottom) } const onWheel = (event: WheelEvent) => { @@ -391,15 +404,28 @@ function useThreadScrollAnchor({ } }, [scrollerRef, stickyBottomRef]) - // Intentionally NO streaming auto-follow. Earlier builds ran a - // ResizeObserver here that re-pinned the viewport to the bottom on every - // content growth while a turn was running, so the chat tracked tokens as - // they streamed. That behavior is removed by request: once a turn is in - // flight the viewport stays exactly where the user left it. The viewport - // is still moved to the bottom ONCE per user submit / new turn / session - // change (see the layout effect and the session-change effect below) so a - // freshly submitted message lands in view — but it does not chase the - // stream afterward. + // Streaming auto-follow: while — and ONLY while — parked at the bottom, chase + // content growth (streaming tokens, late measurement, Shiki re-highlight) so + // the tail stays in view. One upward pixel (scroll/wheel/touch above) flips + // the gate false and following stops until the user returns to the bottom. + // Keyed on the virtualizer's own size signal and pinned in useLayoutEffect — + // the virtualizer's scrollToFn runs in the same pre-paint pass, so the two + // don't fight (no rubber-banding). pinToBottom no-ops at bottom, so rapid + // growth is cheap. + const totalSize = virtualizer.getTotalSize() + const prevTotalSizeRef = useRef(null) + useLayoutEffect(() => { + const prev = prevTotalSizeRef.current + prevTotalSizeRef.current = totalSize + + if (enabled && prev !== null && totalSize > prev && stickyBottomRef.current) { + pinToBottom() + } + }, [enabled, pinToBottom, stickyBottomRef, totalSize]) + + // The floating jump button asks us to return to the bottom; same re-arm + pin + // path as a new turn. + useEffect(() => onScrollToBottomRequest(jumpToBottom), [jumpToBottom]) // Jump to bottom on session change OR when an empty thread first gets // content. Both share the same intent and the same effect. diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 4542b0d02f42..3e9671e21979 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1666,6 +1666,7 @@ export const en: Translations = { stopReading: 'Stop reading', readAloud: 'Read aloud', editMessage: 'Edit message', + scrollToBottom: 'Scroll to bottom', stop: 'Stop', restorePrevious: 'Restore previous checkpoint', restoreCheckpoint: 'Restore checkpoint', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 52a9a1692db5..7b75afa57453 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1325,6 +1325,7 @@ export interface Translations { stopReading: string readAloud: string editMessage: string + scrollToBottom: string stop: string restorePrevious: string restoreCheckpoint: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 67c86c52cba7..9dbe863714af 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1846,6 +1846,7 @@ export const zh: Translations = { stopReading: '停止朗读', readAloud: '朗读', editMessage: '编辑消息', + scrollToBottom: '滚动到底部', stop: '停止', restorePrevious: '恢复上一个检查点', restoreCheckpoint: '恢复检查点', diff --git a/apps/desktop/src/store/thread-scroll.ts b/apps/desktop/src/store/thread-scroll.ts index b577f50404de..c0a9afd741fd 100644 --- a/apps/desktop/src/store/thread-scroll.ts +++ b/apps/desktop/src/store/thread-scroll.ts @@ -1,11 +1,35 @@ -import { atom } from 'nanostores' +import { atom, type WritableAtom } from 'nanostores' +// `$threadScrolledUp` flips the instant the viewport leaves the bottom (dims the +// composer / status stack). `$threadJumpButtonVisible` trips a little further up +// (~10px) so the floating jump control only shows once meaningfully away. export const $threadScrolledUp = atom(false) +export const $threadJumpButtonVisible = atom(false) -export function setThreadScrolledUp(value: boolean) { - if ($threadScrolledUp.get() === value) { - return +// Skip no-op writes so subscribers don't churn on every scroll tick. +const setter = (target: WritableAtom) => (value: boolean) => { + if (target.get() !== value) { + target.set(value) } - - $threadScrolledUp.set(value) } + +export const setThreadScrolledUp = setter($threadScrolledUp) +export const setThreadJumpButtonVisible = setter($threadJumpButtonVisible) + +export const resetThreadScroll = () => { + setThreadScrolledUp(false) + setThreadJumpButtonVisible(false) +} + +// Cross-component bridge: the jump button lives by the composer, the re-arm + +// pin machinery lives in the virtualizer. The virtualizer registers a handler; +// the button fires it. Mirrors the composer focus/insert emitter pattern. +const handlers = new Set<() => void>() + +export const onScrollToBottomRequest = (handler: () => void) => { + handlers.add(handler) + + return () => void handlers.delete(handler) +} + +export const requestScrollToBottom = () => handlers.forEach(handler => handler()) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 31f7c20258c4..ec0665cac7d9 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1269,6 +1269,62 @@ canvas { } } +/* Floating "jump to bottom" control (see scroll-to-bottom-button.tsx). + Directional scale: it contracts toward 1 as it arrives (from 1.1) and keeps + contracting to 0.9 as it leaves — always shrinking in the direction of + travel, so the motion reads as a soft settle / recede rather than a pop. The + X half-offset stays baked into every transform so `left-1/2` centering holds + through the animation. */ +.thread-jump-button { + opacity: 0; + transform: translateX(-50%) translateY(0.3rem) scale(0.9); +} + +.thread-jump-button[data-state='in'] { + animation: thread-jump-in 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.thread-jump-button[data-state='out'] { + animation: thread-jump-out 180ms ease-in forwards; +} + +@keyframes thread-jump-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(0.3rem) scale(1.1); + } + + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +@keyframes thread-jump-out { + from { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } + + to { + opacity: 0; + transform: translateX(-50%) translateY(0.3rem) scale(0.9); + } +} + +@media (prefers-reduced-motion: reduce) { + .thread-jump-button[data-state='in'], + .thread-jump-button[data-state='out'] { + animation: none; + transition: opacity 120ms linear; + } + + .thread-jump-button[data-state='in'] { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + @keyframes code-card-stream-glow { from { border-color: color-mix(in srgb, var(--dt-ring) 18%, var(--ui-stroke-tertiary)); @@ -1290,4 +1346,3 @@ canvas { animation: none; } } - diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 257f775e9ed3..f2ad89f7601c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8750,6 +8750,22 @@ def _cmd_update_impl(args, gateway_mode: bool): except Exception: pass # profiles module not available or no profiles + # Backfill per-profile .env files for profiles created before the + # .env-seeding fix (#44792). Copies the default install's .env so + # those profiles keep the credentials they were effectively using. + try: + from hermes_cli.profiles import backfill_profile_envs + + backfilled = backfill_profile_envs(quiet=True) + if backfilled: + print() + print( + f"→ Seeded .env for {len(backfilled)} profile(s) " + f"(copied from default): {', '.join(backfilled)}" + ) + except Exception: + pass # profiles module not available or no profiles + # Sync Honcho host blocks to all profiles try: from plugins.memory.honcho.cli import sync_honcho_profiles_quiet @@ -9849,7 +9865,10 @@ def cmd_profile(args): getattr(args, "clone_from", None) or get_active_profile_name() ) if clone_all: - print(f"Full copy from {source_label}.") + print( + f"Full copy from {source_label} " + "(excluding session history, backups, and snapshots)." + ) else: print( f"Cloned config, .env, SOUL.md, and skills from {source_label}." diff --git a/hermes_cli/models.py b/hermes_cli/models.py index aa6996a4877d..39decf135faf 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -57,6 +57,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("qwen/qwen3.6-35b-a3b", ""), # MoonshotAI ("moonshotai/kimi-k2.6", "recommended"), + ("moonshotai/kimi-k2.7-code", ""), # MiniMax ("minimax/minimax-m3", ""), # Z-AI @@ -178,6 +179,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "qwen/qwen3.6-35b-a3b", # MoonshotAI "moonshotai/kimi-k2.6", + "moonshotai/kimi-k2.7-code", # MiniMax "minimax/minimax-m3", # Z-AI diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index d15cd0f7e872..50e5bbeabbc9 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -88,9 +88,9 @@ _CLONE_ALL_STRIP: list[str] = [ # node_modules — npm packages (hundreds of MB) # # See ``_DEFAULT_EXPORT_EXCLUDE_ROOT`` below for the broader export-side -# exclusion list (export drops state.db / logs / caches too because the -# archive is a portable snapshot; clone-all keeps those because the cloned -# profile is meant to keep working immediately). +# exclusion list (export also drops logs / caches because the archive is a +# portable snapshot; clone-all keeps those because the cloned profile is +# meant to keep working immediately). _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({ "hermes-agent", ".worktrees", @@ -99,6 +99,30 @@ _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({ "node_modules", }) +# Per-profile history artifacts excluded from --clone-all regardless of the +# source profile. A new profile is a fresh workspace — inheriting the source +# profile's session history, backup archives, or quick-backup snapshots is +# never useful (restoring one inside the clone would resurrect the SOURCE +# profile's state) and can balloon the copy by tens of GB. Unlike +# ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` this set is NOT gated on the default +# profile: named profiles accumulate the same artifacts. +# +# Rationale per item: +# state.db (+wal/shm) — SQLite session store (can reach many GB) +# sessions — per-session transcript/data dirs +# backups — `hermes backup` archives +# state-snapshots — quick-backup snapshot trees +# checkpoints — session checkpoint data +_CLONE_ALL_HISTORY_EXCLUDE_ROOT: frozenset[str] = frozenset({ + "state.db", + "state.db-wal", + "state.db-shm", + "sessions", + "backups", + "state-snapshots", + "checkpoints", +}) + # Marker file written by `hermes profile create --no-skills`. When present in # a profile's root, callers of seed_profile_skills() (fresh-create, `hermes # update`'s all-profile sync, the web dashboard) skip bundled-skill seeding @@ -119,13 +143,16 @@ def has_bundled_skills_opt_out(profile_dir: Path) -> bool: def _clone_all_copytree_ignore(source_dir: Path): """Exclude infrastructure artifacts when cloning a profile via --clone-all. - Two categories: - 1. Root-level entries in ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` — known + Three categories: + 1. Root-level entries in ``_CLONE_ALL_HISTORY_EXCLUDE_ROOT`` — session + history, backups, and snapshots that belong to the SOURCE profile + and should never carry into a fresh clone. Applies to any source. + 2. Root-level entries in ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` — known Hermes infrastructure directories that only the default profile (``~/.hermes``) ever contains. Gated on ``source_dir`` actually being the default profile so a named-profile source never has its own data silently dropped. - 2. Universal exclusions at any depth — Python bytecode caches that + 3. Universal exclusions at any depth — Python bytecode caches that are stale or regenerable (``__pycache__``, ``*.pyc``, ``*.pyo``) and runtime sockets / temp files (``*.sock``, ``*.tmp``). @@ -147,17 +174,21 @@ def _clone_all_copytree_ignore(source_dir: Path): ): ignored.append(entry) continue - # Root-level exclusions only apply when cloning the default profile. - if is_default_source: - try: - if Path(directory).resolve() == source_resolved: - if entry in _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: - ignored.append(entry) - except (OSError, ValueError): - # ``resolve()`` can fail on unusual FS layouts (broken - # symlinks, missing parents). Fail open — better to - # over-copy than silently drop user data. - pass + try: + at_root = Path(directory).resolve() == source_resolved + except (OSError, ValueError): + # ``resolve()`` can fail on unusual FS layouts (broken + # symlinks, missing parents). Fail open — better to + # over-copy than silently drop user data. + at_root = False + if at_root: + # History artifacts: excluded for ANY source profile. + if entry in _CLONE_ALL_HISTORY_EXCLUDE_ROOT: + ignored.append(entry) + continue + # Infrastructure: only the default profile contains these. + if is_default_source and entry in _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: + ignored.append(entry) return ignored return _ignore @@ -946,6 +977,58 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict return None +def backfill_profile_envs(quiet: bool = False) -> List[str]: + """Give every named profile that predates per-profile ``.env`` files one. + + Profiles created before the dashboard/CLI started seeding a ``.env`` + (PR #44792) have none, so once the Channels/Keys endpoints became + profile-scoped those profiles stopped inheriting the root install's + credentials and showed everything as unconfigured. To avoid breaking + anyone on update, copy the DEFAULT install's ``.env`` into each named + profile that lacks one — that preserves the effective credentials those + profiles were already running with (they previously read the root + ``.env`` via the process environment). Users can then diverge per + profile from there. + + Falls back to the placeholder header when the default install has no + ``.env`` itself. Never overwrites an existing profile ``.env``. + + Returns the list of profile names that received a backfilled ``.env``. + """ + backfilled: List[str] = [] + profiles_root = _get_profiles_root() + if not profiles_root.is_dir(): + return backfilled + + default_env = _get_default_hermes_home() / ".env" + + for entry in sorted(profiles_root.iterdir()): + if not entry.is_dir() or not _PROFILE_ID_RE.match(entry.name): + continue + if entry.name == "default": + continue + env_path = entry / ".env" + if env_path.exists(): + continue + try: + if default_env.is_file(): + shutil.copy2(default_env, env_path) + else: + env_path.write_text( + "# Per-profile secrets for this Hermes profile.\n" + "# API keys and tokens set here override the shell environment.\n" + "# Behavioral settings belong in config.yaml, not here.\n", + encoding="utf-8", + ) + os.chmod(str(env_path), 0o600) + backfilled.append(entry.name) + except OSError as e: + if not quiet: + print(f"⚠ Could not seed .env for profile '{entry.name}': {e}") + + return backfilled + + def delete_profile(name: str, yes: bool = False) -> Path: """Delete a profile, its wrapper script, and its gateway service. diff --git a/run_agent.py b/run_agent.py index 8026a602c685..0be8b1763fa4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1560,7 +1560,12 @@ class AIAgent: if not self._session_db_created: self._ensure_db_session() start_idx = len(conversation_history) if conversation_history else 0 - flush_from = max(start_idx, self._last_flushed_db_idx) + # Guard against the flush cursor overshooting the message list. + # This can happen when repair_message_sequence compacts the list + # (merging consecutive users, dropping stray tools) after the + # cursor was set. Fall back to start_idx so we don't skip + # persisting the assistant/tool chain (#44837). + flush_from = max(start_idx, min(self._last_flushed_db_idx, len(messages))) for msg in messages[flush_from:]: role = msg.get("role", "unknown") content = msg.get("content") diff --git a/skills/media/youtube-content/SKILL.md b/skills/media/youtube-content/SKILL.md index 32828f75986b..3661acad126a 100644 --- a/skills/media/youtube-content/SKILL.md +++ b/skills/media/youtube-content/SKILL.md @@ -14,8 +14,11 @@ Extract transcripts from YouTube videos and convert them into useful formats. ## Setup +Use `uv` so the dependency is installed into the same Hermes-managed environment +that runs the helper script: + ```bash -pip install youtube-transcript-api +uv pip install youtube-transcript-api ``` ## Helper Script @@ -24,16 +27,16 @@ pip install youtube-transcript-api ```bash # JSON output with metadata -python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" # Plain text (good for piping into further processing) -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only # With timestamps -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps # Specific language with fallback chain -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en ``` ## Output Formats @@ -59,7 +62,7 @@ After fetching the transcript, format it based on what the user asks for: ## Workflow -1. **Fetch** the transcript using the helper script with `--text-only --timestamps`. +1. **Fetch** the transcript using the helper script with `--text-only --timestamps` via `uv run python3`. 2. **Validate**: confirm the output is non-empty and in the expected language. If empty, retry without `--language` to get any available transcript. If still empty, tell the user the video likely has transcripts disabled. 3. **Chunk if needed**: if the transcript exceeds ~50K characters, split into overlapping chunks (~40K with 2K overlap) and summarize each chunk before merging. 4. **Transform** into the requested output format. If the user did not specify a format, default to a summary. @@ -70,4 +73,4 @@ After fetching the transcript, format it based on what the user asks for: - **Transcript disabled**: tell the user; suggest they check if subtitles are available on the video page. - **Private/unavailable video**: relay the error and ask the user to verify the URL. - **No matching language**: retry without `--language` to fetch any available transcript, then note the actual language to the user. -- **Dependency missing**: run `pip install youtube-transcript-api` and retry. +- **Dependency missing**: run `uv pip install youtube-transcript-api` and retry. diff --git a/skills/media/youtube-content/scripts/fetch_transcript.py b/skills/media/youtube-content/scripts/fetch_transcript.py index 5ad3e5aa652b..6160339038dd 100644 --- a/skills/media/youtube-content/scripts/fetch_transcript.py +++ b/skills/media/youtube-content/scripts/fetch_transcript.py @@ -3,7 +3,7 @@ Fetch a YouTube video transcript and output it as structured JSON. Usage: - python fetch_transcript.py [--language en,tr] [--timestamps] + uv run python3 fetch_transcript.py [--language en,tr] [--timestamps] Output (JSON): { @@ -14,7 +14,7 @@ Output (JSON): "timestamped_text": "00:00 first line\n00:05 second line\n..." } -Install dependency: pip install youtube-transcript-api +Install dependency: uv pip install youtube-transcript-api """ import argparse @@ -56,7 +56,7 @@ def fetch_transcript(video_id: str, languages: list = None): try: from youtube_transcript_api import YouTubeTranscriptApi except ImportError: - print("Error: youtube-transcript-api not installed. Run: pip install youtube-transcript-api", + print("Error: youtube-transcript-api not installed. Run: uv pip install youtube-transcript-api", file=sys.stderr) sys.exit(1) diff --git a/tests/agent/test_compressed_summary_metadata.py b/tests/agent/test_compressed_summary_metadata.py new file mode 100644 index 000000000000..fba47767596a --- /dev/null +++ b/tests/agent/test_compressed_summary_metadata.py @@ -0,0 +1,93 @@ +"""Regression tests for the compressed-summary metadata flag (#38389). + +The compressor marks summary messages with ``COMPRESSED_SUMMARY_METADATA_KEY`` +so frontends (CLI, Desktop, gateway, TUI) can distinguish them from real +assistant/user messages without content-prefix heuristics. + +Two invariants: +1. The flag is present on exactly the summary-bearing message after compress() + (standalone insertion AND merge-into-tail). +2. The key is underscore-prefixed so the chat-completions wire sanitizer + strips it — strict gateways (Fireworks, Mistral, Moonshot/Kimi, + opencode-go) reject unknown message keys with "Extra inputs are not + permitted", poisoning the session. +""" +from unittest.mock import MagicMock, patch + +import pytest + +from agent.context_compressor import ( + COMPRESSED_SUMMARY_METADATA_KEY, + ContextCompressor, +) + + +def _make_compressor(): + with patch( + "agent.context_compressor.get_model_context_length", return_value=8000 + ): + return ContextCompressor( + model="test-model", quiet_mode=True, config_context_length=8000 + ) + + +def _make_messages(n_turns=30): + msgs = [{"role": "system", "content": "sys"}] + for i in range(n_turns): + msgs.append({"role": "user", "content": f"question {i} " + "x" * 400}) + msgs.append({"role": "assistant", "content": f"answer {i} " + "y" * 400}) + return msgs + + +def _compress(cc, msgs): + resp = MagicMock() + resp.choices[0].message.content = "## Active Task\nstuff" + with patch("agent.context_compressor.call_llm", return_value=resp): + return cc.compress(msgs, current_tokens=100_000, force=True) + + +class TestMetadataFlagSet: + def test_exactly_one_flagged_message_after_compress(self): + cc = _make_compressor() + out = _compress(cc, _make_messages()) + flagged = [ + m for m in out + if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY) + ] + assert len(flagged) == 1 + # The flagged message is the one carrying the compaction handoff. + assert "[CONTEXT COMPACTION" in flagged[0]["content"] + + def test_helper_detects_flag(self): + assert ContextCompressor._has_compressed_summary_metadata( + {COMPRESSED_SUMMARY_METADATA_KEY: True} + ) + assert not ContextCompressor._has_compressed_summary_metadata( + {"role": "assistant", "content": "hi"} + ) + assert not ContextCompressor._has_compressed_summary_metadata("not a dict") + assert not ContextCompressor._has_compressed_summary_metadata(None) + + +class TestMetadataFlagNeverReachesWire: + def test_key_is_underscore_prefixed(self): + """The wire sanitizers strip every top-level message key starting + with '_'. A bare key would reach strict gateways (Fireworks etc.) + and 400 with 'Extra inputs are not permitted'.""" + assert COMPRESSED_SUMMARY_METADATA_KEY.startswith("_") + + def test_chat_completions_transport_strips_flag(self): + from agent.transports.chat_completions import ChatCompletionsTransport + + cc = _make_compressor() + out = _compress(cc, _make_messages()) + wire = ChatCompletionsTransport().convert_messages(out, model="some-model") + assert not any( + isinstance(m, dict) and COMPRESSED_SUMMARY_METADATA_KEY in m + for m in wire + ) + # Sanitization must not destroy the in-process flag on the originals. + assert any( + isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY) + for m in out + ) diff --git a/tests/agent/test_compressor_assistant_tail_anchor.py b/tests/agent/test_compressor_assistant_tail_anchor.py new file mode 100644 index 000000000000..e28bc82139f0 --- /dev/null +++ b/tests/agent/test_compressor_assistant_tail_anchor.py @@ -0,0 +1,518 @@ +"""Regression coverage for #29824 — the WebUI session viewer (and TUI +chat panel) was showing the ``[CONTEXT COMPACTION — REFERENCE ONLY]`` +handoff block in the slot where the user had just been reading the +assistant's actual reply, because the previously-visible reply got +rolled into the compaction summary by the token-budget tail walk. + +The fix adds ``_ensure_last_assistant_message_in_tail`` — a mirror of +the existing ``_ensure_last_user_message_in_tail`` (#10896 anchor) — +that pulls ``cut_idx`` back to include the most recent assistant +message with non-empty text content, with the standard tool-group +realignment so we don't orphan a ``tool_call`` / ``tool_result`` pair. + +Pinned here: + +* ``TestFindLastAssistantMessageIdx`` — pure helper contract: + finds the most recent **content-bearing** assistant message, + skips tool-call-only stubs, falls back to "any assistant" only + when no content-bearing reply exists in the compressible region, + honours ``head_end``, returns -1 when there's no assistant at all. + +* ``TestEnsureLastAssistantMessageInTail`` — direct: walks + ``cut_idx`` back when the last reply is in the compressed middle, + is a no-op when it's already in the tail, never crosses + ``head_end``, re-aligns through tool groups. + +* ``TestFindTailCutByTokensAnchorsAssistant`` — integration with + the existing tail-cut path: the exact reporter scenario (long + tool-output run after the previously-visible reply) preserves + the reply; combines with the user anchor for the same-turn + preservation; soft-ceiling overrun no longer hides the reply. + +* ``TestCompactionRollupReproduction`` — end-to-end through + ``compress()`` with a stubbed summariser: pre-fix the reply + text is absorbed into the summary (regression demonstrated by + asserting on the OLD behaviour fails); post-fix the reply text + is still present in the compressed transcript as a regular + assistant message. + +* ``TestSourceGuardrail`` — static asserts on + ``agent/context_compressor.py`` so a future refactor can't + silently drop the anchor. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +@pytest.fixture() +def compressor(): + """ContextCompressor with mocked deps and a tight tail budget so + the helpers' anchor behaviour is observable.""" + from agent.context_compressor import ContextCompressor + with patch( + "agent.context_compressor.get_model_context_length", + return_value=100_000, + ): + c = ContextCompressor( + model="test/model", + threshold_percent=0.85, + protect_first_n=2, + protect_last_n=2, + quiet_mode=True, + ) + c.tail_token_budget = 50 + return c + + +# --------------------------------------------------------------------------- +# Helper: _find_last_assistant_message_idx +# --------------------------------------------------------------------------- + + +class TestFindLastAssistantMessageIdx: + def test_finds_content_bearing_assistant(self, compressor): + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "q"}, + {"role": "assistant", "content": "the reply"}, + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=1) + assert idx == 2 + + def test_skips_tool_call_only_stub_when_text_reply_exists_earlier( + self, compressor + ): + """An assistant message that only carries ``tool_calls`` (no + text content) is not the user-visible reply — the WebUI + renders those as small "calling tool X" indicators. The helper + must prefer the earlier text reply, which is what the user + actually read.""" + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": "VISIBLE REPLY"}, + {"role": "user", "content": "q2"}, + {"role": "assistant", "content": None, + "tool_calls": [{"function": {"name": "t", + "arguments": "{}"}}]}, + {"role": "tool", "content": "result", "tool_call_id": "c1"}, + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=0) + assert idx == 1, ( + "Expected the content-bearing assistant reply (1), not the " + f"trailing tool-call stub. Got {idx}." + ) + + def test_empty_string_content_does_not_count_as_visible(self, compressor): + """An assistant message with ``content=""`` (only whitespace) + is not a visible reply either — common pre-flight stub before + the model streams the real answer.""" + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": "earlier reply"}, + {"role": "user", "content": "q2"}, + {"role": "assistant", "content": " "}, # blank stub + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=0) + # Blank-string assistant message does not count — fall back + # to the earlier real reply. + assert idx == 1 + + def test_multimodal_text_block_counts(self, compressor): + """An assistant with multimodal list-content carrying a text + block (Anthropic / GPT-style ``[{type:text,text:...}]``) + counts as content-bearing.""" + messages = [ + {"role": "user", "content": "q"}, + {"role": "assistant", + "content": [{"type": "text", "text": "hello"}]}, + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=0) + assert idx == 1 + + def test_fallback_to_any_assistant_when_no_content_bearing( + self, compressor + ): + """When there's no text-bearing assistant in the compressible + region (fresh multi-step tool sequence), fall back to the + most recent assistant of any kind so the anchor still works.""" + messages = [ + {"role": "user", "content": "q"}, + {"role": "assistant", "content": None, + "tool_calls": [{"function": {"name": "t", + "arguments": "{}"}}]}, + {"role": "tool", "content": "result", "tool_call_id": "c1"}, + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=0) + assert idx == 1 + + def test_returns_negative_one_when_no_assistant(self, compressor): + messages = [ + {"role": "user", "content": "q1"}, + {"role": "user", "content": "q2"}, + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=0) + assert idx == -1 + + def test_respects_head_end_lower_bound(self, compressor): + """An assistant message at or before ``head_end`` must be + ignored — it's already in the protected head region.""" + messages = [ + {"role": "system", "content": "sys"}, + {"role": "assistant", "content": "in-head reply"}, # idx 1 + {"role": "user", "content": "q"}, + ] + # head_end=2 means the compressible region starts at index 2; + # the assistant at index 1 is in the head and must be skipped. + idx = compressor._find_last_assistant_message_idx(messages, head_end=2) + assert idx == -1 + + +# --------------------------------------------------------------------------- +# Helper: _ensure_last_assistant_message_in_tail +# --------------------------------------------------------------------------- + + +class TestEnsureLastAssistantMessageInTail: + def test_no_op_when_already_in_tail(self, compressor): + messages = [ + {"role": "user", "content": "q"}, + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "q2"}, + ] + # cut_idx=1 means tail starts at index 1 — the reply is already in tail. + new_cut = compressor._ensure_last_assistant_message_in_tail( + messages, cut_idx=1, head_end=0 + ) + assert new_cut == 1 + + def test_walks_cut_idx_back_to_include_reply(self, compressor): + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": "REPLY"}, # idx 1 + {"role": "user", "content": "q2"}, + {"role": "user", "content": "q3"}, + ] + # cut_idx=2 leaves the reply outside the tail; anchor must pull + # cut_idx back to 1 so messages[1:] contains the reply. + new_cut = compressor._ensure_last_assistant_message_in_tail( + messages, cut_idx=2, head_end=0 + ) + assert new_cut == 1 + assert any( + isinstance(m.get("content"), str) and "REPLY" in m["content"] + for m in messages[new_cut:] + ) + + def test_never_crosses_head_end(self, compressor): + messages = [ + {"role": "system", "content": "sys"}, + {"role": "assistant", "content": "in-head"}, # head, must ignore + {"role": "user", "content": "q"}, + ] + # head_end=2 ⇒ assistant at idx 1 is in the head; the anchor + # finds nothing in the compressible region and is a no-op. + new_cut = compressor._ensure_last_assistant_message_in_tail( + messages, cut_idx=3, head_end=2 + ) + assert new_cut == 3 + + def test_re_aligns_through_preceding_tool_group(self, compressor): + """When the anchored assistant is preceded by a + tool_call/result group, ``_align_boundary_backward`` must pull + ``cut_idx`` even further back so the group isn't split — same + guarantee as ``_ensure_last_user_message_in_tail``.""" + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "c1", + "function": {"name": "t", + "arguments": "{}"}}]}, + {"role": "tool", "content": "result", "tool_call_id": "c1"}, + {"role": "assistant", "content": "REPLY"}, # idx 3 + {"role": "user", "content": "q2"}, + ] + # cut_idx=4 leaves the reply outside the tail. Anchor pulls + # back to 3, then _align_boundary_backward sees the preceding + # tool group and pulls further back to 1 (before the assistant + # with tool_calls). + new_cut = compressor._ensure_last_assistant_message_in_tail( + messages, cut_idx=4, head_end=0 + ) + assert new_cut <= 3 + # The tool_call assistant (1) and its tool_result (2) must NOT + # be split: either both in compressed region or both in tail. + if new_cut <= 1: + # Both in tail — tool group intact. + assert messages[new_cut].get("role") == "assistant" + else: + # Otherwise the anchor must land at the reply itself (3). + assert new_cut == 3 + + +# --------------------------------------------------------------------------- +# Integration with _find_tail_cut_by_tokens +# --------------------------------------------------------------------------- + + +class TestFindTailCutByTokensAnchorsAssistant: + def test_reporter_repro_long_tool_run_after_visible_reply( + self, compressor + ): + """The exact #29824 scenario: a tight token budget combined + with a long tail of tool-call/result messages after the + visible reply. Pre-fix, the token-budget walk hit its ceiling + on the tool output and parked ``cut_idx`` past the reply. + Post-fix, the assistant anchor pulls it back.""" + c = compressor + c.tail_token_budget = 10 # force min-tail behaviour + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "msg1"}, # head_end=2 + {"role": "user", "content": "q1"}, + {"role": "assistant", + "content": "PREVIOUSLY VISIBLE REPLY"}, # idx 3 + {"role": "user", "content": "q2"}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "c1", + "function": {"name": "t", + "arguments": "{}"}}]}, + {"role": "tool", "content": "x" * 200, + "tool_call_id": "c1"}, + ] + cut = c._find_tail_cut_by_tokens(messages, head_end=2) + tail_contents = [ + m.get("content") for m in messages[cut:] + if isinstance(m.get("content"), str) + ] + assert any( + "PREVIOUSLY VISIBLE REPLY" in (t or "") for t in tail_contents + ), ( + "REGRESSION (#29824): the visible reply was rolled into " + f"the compaction summary. Tail contents: {tail_contents!r}" + ) + + def test_user_and_assistant_anchors_compose(self, compressor): + """Both anchors run in sequence; the tail must contain both + the latest user message AND the latest visible assistant + reply.""" + c = compressor + c.tail_token_budget = 10 + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": "VISIBLE REPLY"}, + {"role": "user", "content": "follow-up question"}, + {"role": "user", "content": "and another"}, + ] + cut = c._find_tail_cut_by_tokens(messages, head_end=0) + tail_contents = [ + m.get("content") for m in messages[cut:] + if isinstance(m.get("content"), str) + ] + assert any("VISIBLE REPLY" in (t or "") for t in tail_contents) + assert any("and another" in (t or "") for t in tail_contents) + + def test_oversized_tool_output_does_not_strand_reply(self, compressor): + """The soft-ceiling logic in ``_find_tail_cut_by_tokens`` + permits a single oversized tail message; the assistant anchor + must still recover the reply on the other side of it.""" + c = compressor + c.tail_token_budget = 100 # soft ceiling 150 + messages = [ + {"role": "user", "content": "earlier"}, + {"role": "assistant", "content": "VISIBLE REPLY"}, + {"role": "user", "content": "read big file"}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "c1", + "function": {"name": "read", + "arguments": "{}"}}]}, + # ~500 chars ⇒ ~135 tokens, blows past soft ceiling of 150 + {"role": "tool", "content": "y" * 500, + "tool_call_id": "c1"}, + {"role": "user", "content": "ok"}, + ] + cut = c._find_tail_cut_by_tokens(messages, head_end=0) + tail_contents = [ + m.get("content") for m in messages[cut:] + if isinstance(m.get("content"), str) + ] + assert any("VISIBLE REPLY" in (t or "") for t in tail_contents) + + +# --------------------------------------------------------------------------- +# End-to-end: compress() preserves the reply +# --------------------------------------------------------------------------- + + +class TestCompactionRollupReproduction: + """End-to-end through ``compress()``: the visible reply text must + survive in the compressed transcript — either as its own + standalone assistant message OR concatenated onto the merged + summary-handoff tail message (the compressor's double-collision + fallback path; the WebUI re-splits these on the END marker so the + reply renders as a separate bubble — see ``splitCompactionContent`` + in ``web/src/pages/SessionsPage.tsx``).""" + + def test_compress_keeps_visible_reply_text(self, compressor): + from agent.context_compressor import SUMMARY_PREFIX + c = compressor + c.tail_token_budget = 10 + # ``_generate_summary`` normally wraps the LLM body in + # ``SUMMARY_PREFIX`` via ``_with_summary_prefix``; mimic that so + # the merge-into-tail branch can identify the boundary. + _mocked = f"{SUMMARY_PREFIX}\nrolled-up middle summary" + messages = ( + [{"role": "system", "content": "sys"}, + {"role": "user", "content": "initial"}] # head (protect_first_n=2) + # Middle: long enough to be compressible. + + [ + {"role": "user", "content": f"middle q{i}"} + if i % 2 == 0 + else {"role": "assistant", "content": f"middle reply {i}"} + for i in range(12) + ] + + [ + {"role": "user", "content": "the visible question"}, + {"role": "assistant", + "content": "THE VISIBLE REPLY THE USER JUST READ"}, + {"role": "user", "content": "follow up"}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "c1", + "function": {"name": "t", + "arguments": "{}"}}]}, + {"role": "tool", "content": "z" * 500, + "tool_call_id": "c1"}, + ] + ) + with patch.object( + c, "_generate_summary", + return_value=_mocked, + ): + result = c.compress(messages, current_tokens=90_000) + # 1. A summary message exists (compression actually ran). + assert any( + isinstance(m.get("content"), str) + and m["content"].startswith(SUMMARY_PREFIX) + for m in result + ), "compress() did not insert a summary message" + # 2. The visible reply text must survive somewhere — either + # as its own message OR concatenated into the merged tail. + joined = "\n".join( + m.get("content") for m in result + if isinstance(m.get("content"), str) + ) + assert "THE VISIBLE REPLY THE USER JUST READ" in joined, ( + "REGRESSION (#29824): the visible reply was absorbed into " + "the compaction summary AND erased. Compressed transcript " + f"({len(result)} msgs): " + f"{[(m.get('role'), str(m.get('content'))[:50]) for m in result]}" + ) + + def test_standalone_summary_case_keeps_reply_as_own_message( + self, compressor + ): + """When the head and tail roles allow a standalone summary + message (no double-collision), the visible reply must remain + as its OWN assistant message — not merged with anything. + This is the common case; the merge-into-tail path is the + edge case for double-collision.""" + from agent.context_compressor import SUMMARY_PREFIX + c = compressor + c.tail_token_budget = 10 + _mocked = f"{SUMMARY_PREFIX}\nrolled-up middle summary" + # Head ends with ``assistant`` ⇒ summary_role flips to + # ``user`` ⇒ no collision with the assistant tail ⇒ standalone + # summary insert (no merge). + messages = ( + [ + {"role": "user", "content": "initial"}, + {"role": "assistant", "content": "head reply"}, + ] + + [ + {"role": "user", "content": f"middle q{i}"} + if i % 2 == 0 + else {"role": "assistant", "content": f"middle reply {i}"} + for i in range(12) + ] + + [ + {"role": "user", "content": "the visible question"}, + {"role": "assistant", + "content": "THE VISIBLE REPLY THE USER JUST READ"}, + {"role": "user", "content": "follow up"}, + ] + ) + with patch.object( + c, "_generate_summary", + return_value=_mocked, + ): + result = c.compress(messages, current_tokens=90_000) + # Standalone summary present: + summary_rows = [ + m for m in result + if isinstance(m.get("content"), str) + and m["content"].startswith(SUMMARY_PREFIX) + ] + assert len(summary_rows) == 1 + # Visible reply as its OWN distinct assistant message + # (NOT merged into the summary row): + reply_rows = [ + m for m in result + if m.get("role") == "assistant" + and isinstance(m.get("content"), str) + and "THE VISIBLE REPLY THE USER JUST READ" in m["content"] + and not m["content"].startswith(SUMMARY_PREFIX) + ] + assert len(reply_rows) == 1, ( + "REGRESSION (#29824): expected exactly one standalone " + f"assistant message carrying the visible reply, got " + f"{len(reply_rows)}" + ) + + +# --------------------------------------------------------------------------- +# Source guardrail +# --------------------------------------------------------------------------- + + +class TestSourceGuardrail: + @pytest.fixture + def source(self) -> str: + from pathlib import Path + return (Path(__file__).resolve().parents[2] + / "agent" / "context_compressor.py").read_text( + encoding="utf-8") + + def test_helper_defined(self, source): + assert "def _find_last_assistant_message_idx(" in source + assert "def _ensure_last_assistant_message_in_tail(" in source + + def test_anchor_called_from_find_tail_cut(self, source): + """Without the call site the helper is dead code and the bug + regresses silently — pin both the definition AND the wiring.""" + assert "self._ensure_last_assistant_message_in_tail(" in source + + def test_anchor_called_after_user_anchor(self, source): + """The two anchors must run in sequence; reversing or skipping + one drops the corresponding side of the guarantee.""" + user_call = "self._ensure_last_user_message_in_tail(messages, cut_idx, head_end)" + asst_call = "self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end)" + user_idx = source.find(user_call) + asst_idx = source.find(asst_call) + assert user_idx >= 0 and asst_idx >= 0 + assert asst_idx > user_idx, ( + "The assistant anchor must come AFTER the user anchor in " + "``_find_tail_cut_by_tokens`` — each anchor walks cut_idx " + "backward, and ordering keeps the chain monotonic." + ) + + def test_helper_prefers_content_bearing_reply(self, source): + """The helper must skip tool-call-only stubs — that's the + whole user-experience difference between #29824 (no visible + reply) and an in-progress turn (small 'calling tool X' chip).""" + assert "content.strip()" in source + + def test_issue_number_referenced(self, source): + assert "#29824" in source diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 0c56da2687ee..7eb1e8a57b02 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -1252,6 +1252,48 @@ class TestCompressWithClient: "respond to the message below, not the summary above ---" ) + def test_assistant_role_summary_carries_end_marker(self): + """When the summary lands as standalone role='assistant' (head ends + with user), the message body must include the explicit + '--- END OF CONTEXT SUMMARY ---' marker. Without it, models may + regurgitate the summary text as their own output (#33256). + """ + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: stuff happened" + mock_client.chat.completions.create.return_value = mock_response + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) + + # head_last=user → summary_role="assistant" (same setup as + # test_summary_role_avoids_consecutive_user_when_head_ends_with_user). + # With min_tail=3, tail = last 3 messages (indices 5-7). + # head_last=user, tail_first=user → the assistant-role summary does + # not collide with either neighbor and should be inserted standalone. + msgs = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "msg 1"}, + {"role": "user", "content": "msg 2"}, # last head — user + {"role": "assistant", "content": "msg 3"}, + {"role": "user", "content": "msg 4"}, + {"role": "user", "content": "msg 5"}, + {"role": "assistant", "content": "msg 6"}, + {"role": "user", "content": "msg 7"}, + ] + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs) + + summary_msg = next( + m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX) + ) + assert summary_msg["role"] == "assistant" + assert "END OF CONTEXT SUMMARY" in summary_msg["content"] + assert summary_msg["content"].rstrip().endswith( + "respond to the message below, not the summary above ---" + ) + def test_summary_role_avoids_consecutive_user_messages(self): """Summary role should alternate with the last head message to avoid consecutive same-role messages.""" mock_client = MagicMock() @@ -1762,6 +1804,40 @@ class TestTokenBudgetTailProtection: tail_size = len(messages) - cut assert tail_size >= 3, f"Tail is only {tail_size} messages, min should be 3" + def test_tiny_budget_preserves_bounded_recent_turns(self, budget_compressor): + """A token-exhausted tail must preserve more than just the latest ask. + + Regression for #9413: the previous hard-coded 3-message floor could + leave the latest user message live while summarizing the assistant/tool + context immediately before it, which made the post-compression turn feel + like a fresh conversation. + """ + c = budget_compressor + c.tail_token_budget = 10 + c.protect_last_n = 20 + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "old start"}, + {"role": "assistant", "content": "old ack"}, + {"role": "user", "content": "middle work"}, + {"role": "assistant", "content": "middle ack"}, + {"role": "user", "content": "middle ask 2"}, + {"role": "assistant", "content": "middle answer 2"}, + {"role": "user", "content": "middle ask 3"}, + {"role": "assistant", "content": "middle answer 3"}, + {"role": "user", "content": "recent ask 1"}, + {"role": "assistant", "content": "recent answer 1"}, + {"role": "user", "content": "recent ask 2"}, + {"role": "assistant", "content": "recent answer 2"}, + {"role": "user", "content": "latest ask"}, + ] + + cut = c._find_tail_cut_by_tokens(messages, head_end=1) + + assert len(messages) - cut >= 8 + assert messages[cut]["content"] == "middle answer 2" + assert messages[-1]["content"] == "latest ask" + def test_soft_ceiling_allows_oversized_message(self, budget_compressor): """The 1.5x soft ceiling allows an oversized message to be included rather than splitting it.""" diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 31d56b9b9839..2a23b648baa7 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -33,6 +33,7 @@ from hermes_cli.profiles import ( seed_profile_skills, has_bundled_skills_opt_out, NO_BUNDLED_SKILLS_MARKER, + backfill_profile_envs, ) @@ -268,9 +269,9 @@ class TestCreateProfile: def test_clone_all_excludes_default_infrastructure(self, profile_env): """--clone-all from default profile excludes hermes-agent, .worktrees, bin, node_modules at root, plus __pycache__/*.pyc/*.pyo/*.sock/*.tmp - at any depth. Profile data (config, env, skills, sessions, logs, - state.db) must be preserved — clone-all means "complete snapshot - minus infrastructure." + at any depth. Profile data (config, env, skills, logs) must be + preserved — clone-all means "complete snapshot minus infrastructure + and per-profile history." """ tmp_path = profile_env default_home = tmp_path / ".hermes" @@ -296,8 +297,6 @@ class TestCreateProfile: (default_home / "skills" / "my-skill" / "SKILL.md").write_text("skill") (default_home / "config.yaml").write_text("model: gpt-4") (default_home / ".env").write_text("KEY=val") - (default_home / "state.db").write_text("sessions-data") - (default_home / "sessions").mkdir(exist_ok=True) (default_home / "logs").mkdir(exist_ok=True) (default_home / "logs" / "gateway.log").write_text("log") @@ -319,10 +318,40 @@ class TestCreateProfile: assert (profile_dir / "skills" / "my-skill" / "SKILL.md").read_text() == "skill" assert (profile_dir / "config.yaml").read_text() == "model: gpt-4" assert (profile_dir / ".env").read_text() == "KEY=val" - assert (profile_dir / "state.db").read_text() == "sessions-data" - assert (profile_dir / "sessions").exists() assert (profile_dir / "logs" / "gateway.log").read_text() == "log" + def test_clone_all_excludes_history_artifacts(self, profile_env): + """--clone-all excludes the source's session history, backups, and + snapshots — a clone is a fresh workspace, and these can reach tens + of GB. Applies to ANY source profile, not just default. + """ + tmp_path = profile_env + default_home = tmp_path / ".hermes" + (default_home / "state.db").write_text("sessions-data") + (default_home / "state.db-wal").write_text("wal") + (default_home / "state.db-shm").write_text("shm") + (default_home / "sessions" / "20260101_old").mkdir(parents=True) + (default_home / "backups").mkdir(exist_ok=True) + (default_home / "backups" / "backup.tar.gz").write_text("archive") + (default_home / "state-snapshots" / "snap1").mkdir(parents=True) + (default_home / "checkpoints" / "cp1").mkdir(parents=True) + # Data that should still copy + (default_home / "config.yaml").write_text("model: gpt-4") + # Nested dirs with the same names must NOT be excluded (root-only) + (default_home / "workspace" / "backups").mkdir(parents=True) + (default_home / "workspace" / "backups" / "user-data.txt").write_text("mine") + + profile_dir = create_profile("fresh", clone_all=True, no_alias=True) + + for history in ( + "state.db", "state.db-wal", "state.db-shm", + "sessions", "backups", "state-snapshots", "checkpoints", + ): + assert not (profile_dir / history).exists(), history + assert (profile_dir / "config.yaml").read_text() == "model: gpt-4" + # Root-only: nested same-name dirs survive + assert (profile_dir / "workspace" / "backups" / "user-data.txt").read_text() == "mine" + def test_clone_config_missing_files_skipped(self, profile_env): """Clone config gracefully skips files that don't exist in source.""" profile_dir = create_profile("coder", clone_config=True, no_alias=True) @@ -445,6 +474,60 @@ class TestNoSkillsOptOut: assert len(called) == 1 +# =================================================================== +# TestBackfillProfileEnvs +# =================================================================== + +class TestBackfillProfileEnvs: + """Tests for backfill_profile_envs() — the `hermes update` pass that + gives pre-#44792 profiles (created before .env seeding) their own + .env, copied from the default install so credentials don't break.""" + + def test_copies_default_env_into_envless_profiles(self, profile_env): + import stat + tmp_path = profile_env + (tmp_path / ".hermes" / ".env").write_text("OPENROUTER_API_KEY=root-key\n") + p1 = create_profile("old1", no_alias=True) + p2 = create_profile("old2", no_alias=True) + # Simulate pre-#44792 profiles: no .env + (p1 / ".env").unlink() + (p2 / ".env").unlink() + + backfilled = backfill_profile_envs(quiet=True) + + assert sorted(backfilled) == ["old1", "old2"] + for p in (p1, p2): + assert (p / ".env").read_text() == "OPENROUTER_API_KEY=root-key\n" + assert stat.S_IMODE((p / ".env").stat().st_mode) == 0o600 + + def test_never_overwrites_existing_profile_env(self, profile_env): + tmp_path = profile_env + (tmp_path / ".hermes" / ".env").write_text("KEY=root\n") + p = create_profile("hasenv", no_alias=True) + (p / ".env").write_text("KEY=mine\n") + + backfilled = backfill_profile_envs(quiet=True) + + assert backfilled == [] + assert (p / ".env").read_text() == "KEY=mine\n" + + def test_placeholder_when_default_has_no_env(self, profile_env): + p = create_profile("noroot", no_alias=True) + (p / ".env").unlink() + + backfilled = backfill_profile_envs(quiet=True) + + assert backfilled == ["noroot"] + content = (p / ".env").read_text(encoding="utf-8") + assert all( + line.startswith("#") or not line.strip() + for line in content.splitlines() + ) + + def test_no_profiles_root_is_noop(self, profile_env): + assert backfill_profile_envs(quiet=True) == [] + + # =================================================================== # TestDeleteProfile # =================================================================== diff --git a/tests/run_agent/test_message_sequence_repair.py b/tests/run_agent/test_message_sequence_repair.py index fd1db95e8436..8fba45ebeb2a 100644 --- a/tests/run_agent/test_message_sequence_repair.py +++ b/tests/run_agent/test_message_sequence_repair.py @@ -199,3 +199,102 @@ def test_repair_preserves_system_messages(): AIAgent._repair_message_sequence(agent, messages) assert messages == original + + +# ── repair_message_sequence_with_cursor (#44837) ─────────────────────────── + +from agent.agent_runtime_helpers import repair_message_sequence_with_cursor + + +def test_cursor_clamped_when_compaction_shrinks_below_cursor(): + """Cursor past the new end of the list must come back in range so the + turn-end flush doesn't skip the assistant/tool chain (#44837).""" + agent = _bare_agent() + messages = [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "second"}, + ] + agent._last_flushed_db_idx = 2 # both rows already flushed + + repairs = repair_message_sequence_with_cursor(agent, messages) + + assert repairs == 1 + assert len(messages) == 1 + assert agent._last_flushed_db_idx == 1 + + +def test_cursor_rewinds_when_compaction_happens_before_cursor(): + """Repair that drops/merges messages at indexes BELOW the cursor must + rewind it by the number removed, or unflushed rows get skipped. + A plain min() clamp does NOT catch this case.""" + agent = _bare_agent() + flushed_a = {"role": "user", "content": "first"} + flushed_b = {"role": "user", "content": "second"} # merged into flushed_a + unflushed_assistant = {"role": "assistant", "content": "answer"} + messages = [flushed_a, flushed_b, unflushed_assistant] + agent._last_flushed_db_idx = 2 # the two user rows are flushed + + repairs = repair_message_sequence_with_cursor(agent, messages) + + assert repairs == 1 + assert len(messages) == 2 + # Cursor must now point at the assistant (index 1), not stay at 2 — + # min(2, len=2) would leave it at 2 and the flush would skip it. + assert agent._last_flushed_db_idx == 1 + assert messages[agent._last_flushed_db_idx] is unflushed_assistant + + +def test_cursor_untouched_when_no_repairs(): + agent = _bare_agent() + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + agent._last_flushed_db_idx = 1 + + repairs = repair_message_sequence_with_cursor(agent, messages) + + assert repairs == 0 + assert agent._last_flushed_db_idx == 1 + + +def test_cursor_helper_safe_without_cursor_attribute(): + """Bare agents (no _last_flushed_db_idx) must not crash.""" + agent = _bare_agent() + messages = [ + {"role": "user", "content": "a"}, + {"role": "user", "content": "b"}, + ] + + repairs = repair_message_sequence_with_cursor(agent, messages) + + assert repairs == 1 + assert not hasattr(agent, "_last_flushed_db_idx") + + +def test_flush_guard_clamps_overshooting_cursor(): + """_flush_messages_to_session_db safety net: an overshooting cursor must + not produce a negative-start slice that skips everything (#44837).""" + + class _DB: + def __init__(self): + self.rows = [] + + def append_message(self, **kw): + self.rows.append(kw) + + agent = _bare_agent() + agent._session_db = _DB() + agent._session_db_created = True + agent.session_id = "s1" + agent._persist_user_message_override = None + agent._last_flushed_db_idx = 5 # stale — past end of compacted list + messages = [ + {"role": "user", "content": "q"}, + {"role": "assistant", "content": "a"}, + ] + + AIAgent._flush_messages_to_session_db(agent, messages, conversation_history=[]) + + # min(5, 2) = 2 → nothing skipped below start_idx, cursor settles at 2 + assert agent._last_flushed_db_idx == 2 diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 34a68800d060..c48d24538766 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -147,6 +147,60 @@ function ToolCallBlock({ ); } +// Context-compaction handoff blocks are persisted as ``role="user"`` or +// ``role="assistant"`` with content starting with one of these prefixes — +// they're metadata inserted by ``agent/context_compressor.py``, NOT real +// turns the user typed or the model replied with. Rendering them with +// the same styling as regular messages confuses operators scrolling the +// session timeline (#29824 — "WebUI can show context compaction block +// instead of latest assistant response after compression"), so we +// detect them here and downgrade them to a muted, clearly-labelled +// "Context handoff" row. +// +// 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; + +// 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, @@ -180,12 +234,60 @@ function MessageBubble({ text: "text-warning", label: t.sessions.roles.tool, }, + // Compaction handoffs render as faded system-style metadata with a + // distinctive label so they can't be mistaken for real assistant + // replies during a scroll-back review (#29824). + compaction: { + bg: "bg-muted/50", + text: "text-muted-foreground italic", + label: "Context handoff", + }, }; - const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system; - const label = msg.tool_name - ? `${t.sessions.roles.tool}: ${msg.tool_name}` - : style.label; + // 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 + // + ``. 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 ( + <> + + + + ); + } + + const isCompaction = compactionSplit !== null; + const style = isCompaction + ? ROLE_STYLES.compaction + : ROLE_STYLES[msg.role] ?? ROLE_STYLES.system; + const label = isCompaction + ? ROLE_STYLES.compaction.label + : msg.tool_name + ? `${t.sessions.roles.tool}: ${msg.tool_name}` + : style.label; // Check if any search term appears as a prefix of any word in content const isHit = (() => { diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md index 922de3790cfc..a1b09ed9a593 100644 --- a/website/docs/reference/profile-commands.md +++ b/website/docs/reference/profile-commands.md @@ -81,7 +81,7 @@ Creates a new profile. |-------------------|-------------| | `` | Name for the new profile. Must be a valid directory name (alphanumeric, hyphens, underscores). | | `--clone` | Copy `config.yaml`, `.env`, and `SOUL.md` from the current profile. | -| `--clone-all` | Copy everything (config, memories, skills, sessions, state) from the current profile. | +| `--clone-all` | Copy everything (config, memories, skills, cron, plugins) from the current profile. Excludes per-profile history: sessions, `state.db`, backups, state-snapshots, checkpoints. | | `--clone-from ` | Clone from a specific profile instead of the current one. Used with `--clone` or `--clone-all`. | | `--no-alias` | Skip wrapper script creation. | | `--description ""` | One- or two-sentence description of what this profile is good at. Used by the kanban orchestrator to route tasks based on role instead of profile name alone. Skip and add later via `hermes profile describe`. Persisted in `/profile.yaml`. | diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index 2efb2a9f867b..6e583db2b005 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -58,7 +58,7 @@ Copies your current profile's `config.yaml`, `.env`, and `SOUL.md` into the new hermes profile create backup --clone-all ``` -Copies **everything** — config, API keys, personality, all memories, full session history, skills, cron jobs, plugins. A complete snapshot. Useful for backups or forking an agent that already has context. +Copies **everything** — config, API keys, personality, all memories, skills, cron jobs, plugins. A complete working snapshot. Per-profile history is excluded (session history, `state.db`, `backups/`, `state-snapshots/`, `checkpoints/`) — these belong to the source profile and can reach tens of GB. For a full backup including history, use `hermes profile export` or `hermes backup` instead. ### Clone from a specific profile diff --git a/website/docs/user-guide/skills/bundled/media/media-youtube-content.md b/website/docs/user-guide/skills/bundled/media/media-youtube-content.md index 24f8871a9724..2971a56f9551 100644 --- a/website/docs/user-guide/skills/bundled/media/media-youtube-content.md +++ b/website/docs/user-guide/skills/bundled/media/media-youtube-content.md @@ -34,8 +34,11 @@ Extract transcripts from YouTube videos and convert them into useful formats. ## Setup +Use `uv` so the dependency is installed into the same Hermes-managed environment +that runs the helper script: + ```bash -pip install youtube-transcript-api +uv pip install youtube-transcript-api ``` ## Helper Script @@ -44,16 +47,16 @@ pip install youtube-transcript-api ```bash # JSON output with metadata -python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" # Plain text (good for piping into further processing) -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only # With timestamps -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps # Specific language with fallback chain -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en ``` ## Output Formats @@ -79,7 +82,7 @@ After fetching the transcript, format it based on what the user asks for: ## Workflow -1. **Fetch** the transcript using the helper script with `--text-only --timestamps`. +1. **Fetch** the transcript using the helper script with `--text-only --timestamps` via `uv run python3`. 2. **Validate**: confirm the output is non-empty and in the expected language. If empty, retry without `--language` to get any available transcript. If still empty, tell the user the video likely has transcripts disabled. 3. **Chunk if needed**: if the transcript exceeds ~50K characters, split into overlapping chunks (~40K with 2K overlap) and summarize each chunk before merging. 4. **Transform** into the requested output format. If the user did not specify a format, default to a summary. @@ -90,4 +93,4 @@ After fetching the transcript, format it based on what the user asks for: - **Transcript disabled**: tell the user; suggest they check if subtitles are available on the video page. - **Private/unavailable video**: relay the error and ask the user to verify the URL. - **No matching language**: retry without `--language` to fetch any available transcript, then note the actual language to the user. -- **Dependency missing**: run `pip install youtube-transcript-api` and retry. +- **Dependency missing**: run `uv pip install youtube-transcript-api` and retry. diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 11518d7b414b..b028ecd92457 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-06-09T17:20:16Z", + "updated_at": "2026-06-12T23:38:08Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -84,6 +84,10 @@ "id": "moonshotai/kimi-k2.6", "description": "recommended" }, + { + "id": "moonshotai/kimi-k2.7-code", + "description": "" + }, { "id": "minimax/minimax-m3", "description": "" @@ -199,6 +203,9 @@ { "id": "moonshotai/kimi-k2.6" }, + { + "id": "moonshotai/kimi-k2.7-code" + }, { "id": "minimax/minimax-m3" },