Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/sidebar-workspace-dedup

This commit is contained in:
Brooklyn Nicholson 2026-06-12 19:40:24 -05:00
commit a1c6349c1f
27 changed files with 1576 additions and 104 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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)",

View file

@ -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({
</Suspense>
)}
</AssistantRuntimeProvider>
{showChatBar && <ScrollToBottomButton />}
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>

View file

@ -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 (
<button
aria-hidden={!visible}
aria-label={t.assistant.thread.scrollToBottom}
className={cn(
'thread-jump-button absolute left-1/2 z-20 grid size-8 place-items-center rounded-full',
'border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
'backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
!visible && 'pointer-events-none'
)}
data-state={state}
onClick={() => {
triggerHaptic('selection')
requestScrollToBottom()
}}
style={{
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.625rem)'
}}
tabIndex={visible ? 0 : -1}
type="button"
>
<Codicon name="arrow-down" size="1rem" />
</button>
)
}

View file

@ -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<typeof ThreadPrimitive.MessageByIndex>['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<number | null>(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.

View file

@ -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',

View file

@ -1325,6 +1325,7 @@ export interface Translations {
stopReading: string
readAloud: string
editMessage: string
scrollToBottom: string
stop: string
restorePrevious: string
restoreCheckpoint: string

View file

@ -1846,6 +1846,7 @@ export const zh: Translations = {
stopReading: '停止朗读',
readAloud: '朗读',
editMessage: '编辑消息',
scrollToBottom: '滚动到底部',
stop: '停止',
restorePrevious: '恢复上一个检查点',
restoreCheckpoint: '恢复检查点',

View file

@ -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<boolean>) => (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())

View file

@ -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;
}
}

View file

@ -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}."

View file

@ -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

View file

@ -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.

View file

@ -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")

View file

@ -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.

View file

@ -3,7 +3,7 @@
Fetch a YouTube video transcript and output it as structured JSON.
Usage:
python fetch_transcript.py <url_or_video_id> [--language en,tr] [--timestamps]
uv run python3 fetch_transcript.py <url_or_video_id> [--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)

View file

@ -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
)

View file

@ -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

View file

@ -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."""

View file

@ -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
# ===================================================================

View file

@ -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

View file

@ -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
// + <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;
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 = (() => {

View file

@ -81,7 +81,7 @@ Creates a new profile.
|-------------------|-------------|
| `<name>` | 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 <profile>` | Clone from a specific profile instead of the current one. Used with `--clone` or `--clone-all`. |
| `--no-alias` | Skip wrapper script creation. |
| `--description "<text>"` | 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_dir>/profile.yaml`. |

View file

@ -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

View file

@ -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.

View file

@ -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"
},