mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/sidebar-workspace-dedup
This commit is contained in:
commit
a1c6349c1f
27 changed files with 1576 additions and 104 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
58
apps/desktop/src/app/chat/scroll-to-bottom-button.tsx
Normal file
58
apps/desktop/src/app/chat/scroll-to-bottom-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1325,6 +1325,7 @@ export interface Translations {
|
|||
stopReading: string
|
||||
readAloud: string
|
||||
editMessage: string
|
||||
scrollToBottom: string
|
||||
stop: string
|
||||
restorePrevious: string
|
||||
restoreCheckpoint: string
|
||||
|
|
|
|||
|
|
@ -1846,6 +1846,7 @@ export const zh: Translations = {
|
|||
stopReading: '停止朗读',
|
||||
readAloud: '朗读',
|
||||
editMessage: '编辑消息',
|
||||
scrollToBottom: '滚动到底部',
|
||||
stop: '停止',
|
||||
restorePrevious: '恢复上一个检查点',
|
||||
restoreCheckpoint: '恢复检查点',
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
93
tests/agent/test_compressed_summary_metadata.py
Normal file
93
tests/agent/test_compressed_summary_metadata.py
Normal 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
|
||||
)
|
||||
518
tests/agent/test_compressor_assistant_tail_anchor.py
Normal file
518
tests/agent/test_compressor_assistant_tail_anchor.py
Normal 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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ===================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = (() => {
|
||||
|
|
|
|||
|
|
@ -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`. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue