From 749b7219c46820d2f928efe37d28ed768669e5fa Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Wed, 27 May 2026 18:10:14 +0200 Subject: [PATCH 01/15] fix(compression): always append END OF CONTEXT SUMMARY marker to standalone summaries regardless of role When the compression summary lands as an assistant-role message (head ends with user), the end marker was not appended. Models may regurgitate the summary text as their own visible output when there's no clear boundary signal (#33256). The end marker was already appended for user-role summaries (#11475, #14521) but the assistant-role path was missed in the original fix. This ensures ALL standalone summary messages carry the boundary marker, preventing summary text from leaking into user-visible chat output. --- agent/context_compressor.py | 10 +++--- tests/agent/test_context_compressor.py | 42 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 4611616085f8..dada8ebacc79 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -2193,10 +2193,12 @@ 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": + # 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--- END OF CONTEXT SUMMARY — " diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 0c56da2687ee..b121192bd170 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -1252,6 +1252,48 @@ class TestCompressWithClient: "respond to the message below, not the summary above ---" ) + def test_assistant_role_summary_carries_end_marker(self): + """When the summary lands as standalone role='assistant' (head ends + with user), the message body must include the explicit + '--- END OF CONTEXT SUMMARY ---' marker. Without it, models may + regurgitate the summary text as their own output (#33256). + """ + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: stuff happened" + mock_client.chat.completions.create.return_value = mock_response + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) + + # head_last=user → summary_role="assistant" (same setup as + # test_summary_role_avoids_consecutive_user_when_head_ends_with_user). + # With min_tail=3, tail = last 3 messages (indices 5-7). + # head_last=user, tail_first=user → the assistant-role summary does + # not collide with either neighbor and should be inserted standalone. + msgs = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "msg 1"}, + {"role": "user", "content": "msg 2"}, # last head — user + {"role": "assistant", "content": "msg 3"}, + {"role": "user", "content": "msg 4"}, + {"role": "user", "content": "msg 5"}, + {"role": "assistant", "content": "msg 6"}, + {"role": "user", "content": "msg 7"}, + ] + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs) + + summary_msg = next( + m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX) + ) + assert summary_msg["role"] == "assistant" + assert "END OF CONTEXT SUMMARY" in summary_msg["content"] + assert summary_msg["content"].rstrip().endswith( + "respond to the message below, not the summary above ---" + ) + def test_summary_role_avoids_consecutive_user_messages(self): """Summary role should alternate with the last head message to avoid consecutive same-role messages.""" mock_client = MagicMock() From 0db5cb8e7541c3713c0e14e09e9fcc5d99193ca7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:33:27 -0700 Subject: [PATCH 02/15] refactor(agent): hoist summary end marker to _SUMMARY_END_MARKER; strip it on rehydration Follow-up to the #33346 cherry-pick: - the marker string was duplicated at both insertion sites (standalone + merged-into-tail); hoist to a module constant - _strip_summary_prefix now also strips a trailing end marker so a rehydrated handoff body doesn't leak the boundary directive into the iterative-update summarizer prompt (it is re-appended on insertion) --- agent/context_compressor.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index dada8ebacc79..57faf09d9d74 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -69,6 +69,16 @@ SUMMARY_PREFIX = ( ) LEGACY_SUMMARY_PREFIX = "[CONTEXT 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 @@ -1616,7 +1626,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 @@ -2199,11 +2215,7 @@ This compaction should PRIORITISE preserving all information related to the focu # 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--- END OF CONTEXT SUMMARY — " - "respond to the message below, not the summary above ---" - ) + summary = summary + "\n\n" + _SUMMARY_END_MARKER if not _merge_summary_into_tail: compressed.append({"role": summary_role, "content": summary}) @@ -2211,11 +2223,7 @@ This compaction should PRIORITISE preserving all information related to the focu 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, From 7a318aae22a68f986dcd937bdcf9fc82de6c07d3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:41:50 -0700 Subject: [PATCH 03/15] fix(profiles): exclude session history, backups, and snapshots from --clone-all (#45246) --clone-all copied the source profile's state.db, sessions/, backups/, state-snapshots/, and checkpoints/ into the new profile. These are per-profile history: a 49GB copy in practice (15GB snapshots + 11GB backup archives + 16GB state.db + 6.4GB sessions), and restoring a copied backup inside the clone would resurrect the SOURCE profile's state. A clone is a fresh workspace; history stays with the source. New _CLONE_ALL_HISTORY_EXCLUDE_ROOT set, applied at root level for ANY source profile (named profiles accumulate the same artifacts), unlike the default-gated infrastructure excludes. Nested same-name dirs still copy. Docs and the post-create CLI message updated to match; profile export / hermes backup remain the full-history paths. --- hermes_cli/main.py | 5 +- hermes_cli/profiles.py | 65 ++++++++++++++++------ tests/hermes_cli/test_profiles.py | 42 +++++++++++--- website/docs/reference/profile-commands.md | 2 +- website/docs/user-guide/profiles.md | 2 +- 5 files changed, 89 insertions(+), 27 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 257f775e9ed3..841d8bc947c5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9849,7 +9849,10 @@ def cmd_profile(args): getattr(args, "clone_from", None) or get_active_profile_name() ) if clone_all: - print(f"Full copy from {source_label}.") + print( + f"Full copy from {source_label} " + "(excluding session history, backups, and snapshots)." + ) else: print( f"Cloned config, .env, SOUL.md, and skills from {source_label}." diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index d15cd0f7e872..f30eb70650e1 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -88,9 +88,9 @@ _CLONE_ALL_STRIP: list[str] = [ # node_modules — npm packages (hundreds of MB) # # See ``_DEFAULT_EXPORT_EXCLUDE_ROOT`` below for the broader export-side -# exclusion list (export drops state.db / logs / caches too because the -# archive is a portable snapshot; clone-all keeps those because the cloned -# profile is meant to keep working immediately). +# exclusion list (export also drops logs / caches because the archive is a +# portable snapshot; clone-all keeps those because the cloned profile is +# meant to keep working immediately). _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({ "hermes-agent", ".worktrees", @@ -99,6 +99,30 @@ _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({ "node_modules", }) +# Per-profile history artifacts excluded from --clone-all regardless of the +# source profile. A new profile is a fresh workspace — inheriting the source +# profile's session history, backup archives, or quick-backup snapshots is +# never useful (restoring one inside the clone would resurrect the SOURCE +# profile's state) and can balloon the copy by tens of GB. Unlike +# ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` this set is NOT gated on the default +# profile: named profiles accumulate the same artifacts. +# +# Rationale per item: +# state.db (+wal/shm) — SQLite session store (can reach many GB) +# sessions — per-session transcript/data dirs +# backups — `hermes backup` archives +# state-snapshots — quick-backup snapshot trees +# checkpoints — session checkpoint data +_CLONE_ALL_HISTORY_EXCLUDE_ROOT: frozenset[str] = frozenset({ + "state.db", + "state.db-wal", + "state.db-shm", + "sessions", + "backups", + "state-snapshots", + "checkpoints", +}) + # Marker file written by `hermes profile create --no-skills`. When present in # a profile's root, callers of seed_profile_skills() (fresh-create, `hermes # update`'s all-profile sync, the web dashboard) skip bundled-skill seeding @@ -119,13 +143,16 @@ def has_bundled_skills_opt_out(profile_dir: Path) -> bool: def _clone_all_copytree_ignore(source_dir: Path): """Exclude infrastructure artifacts when cloning a profile via --clone-all. - Two categories: - 1. Root-level entries in ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` — known + Three categories: + 1. Root-level entries in ``_CLONE_ALL_HISTORY_EXCLUDE_ROOT`` — session + history, backups, and snapshots that belong to the SOURCE profile + and should never carry into a fresh clone. Applies to any source. + 2. Root-level entries in ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` — known Hermes infrastructure directories that only the default profile (``~/.hermes``) ever contains. Gated on ``source_dir`` actually being the default profile so a named-profile source never has its own data silently dropped. - 2. Universal exclusions at any depth — Python bytecode caches that + 3. Universal exclusions at any depth — Python bytecode caches that are stale or regenerable (``__pycache__``, ``*.pyc``, ``*.pyo``) and runtime sockets / temp files (``*.sock``, ``*.tmp``). @@ -147,17 +174,21 @@ def _clone_all_copytree_ignore(source_dir: Path): ): ignored.append(entry) continue - # Root-level exclusions only apply when cloning the default profile. - if is_default_source: - try: - if Path(directory).resolve() == source_resolved: - if entry in _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: - ignored.append(entry) - except (OSError, ValueError): - # ``resolve()`` can fail on unusual FS layouts (broken - # symlinks, missing parents). Fail open — better to - # over-copy than silently drop user data. - pass + try: + at_root = Path(directory).resolve() == source_resolved + except (OSError, ValueError): + # ``resolve()`` can fail on unusual FS layouts (broken + # symlinks, missing parents). Fail open — better to + # over-copy than silently drop user data. + at_root = False + if at_root: + # History artifacts: excluded for ANY source profile. + if entry in _CLONE_ALL_HISTORY_EXCLUDE_ROOT: + ignored.append(entry) + continue + # Infrastructure: only the default profile contains these. + if is_default_source and entry in _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: + ignored.append(entry) return ignored return _ignore diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 31d56b9b9839..c38b1f0655dc 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -268,9 +268,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 +296,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 +317,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) diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md index 922de3790cfc..a1b09ed9a593 100644 --- a/website/docs/reference/profile-commands.md +++ b/website/docs/reference/profile-commands.md @@ -81,7 +81,7 @@ Creates a new profile. |-------------------|-------------| | `` | Name for the new profile. Must be a valid directory name (alphanumeric, hyphens, underscores). | | `--clone` | Copy `config.yaml`, `.env`, and `SOUL.md` from the current profile. | -| `--clone-all` | Copy everything (config, memories, skills, sessions, state) from the current profile. | +| `--clone-all` | Copy everything (config, memories, skills, cron, plugins) from the current profile. Excludes per-profile history: sessions, `state.db`, backups, state-snapshots, checkpoints. | | `--clone-from ` | Clone from a specific profile instead of the current one. Used with `--clone` or `--clone-all`. | | `--no-alias` | Skip wrapper script creation. | | `--description ""` | One- or two-sentence description of what this profile is good at. Used by the kanban orchestrator to route tasks based on role instead of profile name alone. Skip and add later via `hermes profile describe`. Persisted in `/profile.yaml`. | diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index 2efb2a9f867b..6e583db2b005 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -58,7 +58,7 @@ Copies your current profile's `config.yaml`, `.env`, and `SOUL.md` into the new hermes profile create backup --clone-all ``` -Copies **everything** — config, API keys, personality, all memories, full session history, skills, cron jobs, plugins. A complete snapshot. Useful for backups or forking an agent that already has context. +Copies **everything** — config, API keys, personality, all memories, skills, cron jobs, plugins. A complete working snapshot. Per-profile history is excluded (session history, `state.db`, `backups/`, `state-snapshots/`, `checkpoints/`) — these belong to the source profile and can reach tens of GB. For a full backup including history, use `hermes profile export` or `hermes backup` instead. ### Clone from a specific profile From 691ff7c1887de4dac853ac79fee48d681e110de6 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 21 May 2026 20:59:18 +0700 Subject: [PATCH 04/15] fix(compressor): keep last visible assistant reply out of compaction summary + label handoffs in WebUI (#29824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-pronged fix for the WebUI "context compaction block in place of last assistant response" regression. Agent layer (the real fix). ``_find_tail_cut_by_tokens`` already had ``_ensure_last_user_message_in_tail`` to keep the most recent user request out of the compressed middle (#10896), but no symmetric anchor for the assistant side. When the conversation has an oversized recent tool result or a long stretch of tool-call/result pairs *after* the assistant's last visible reply, the token-budget walk can stop with the previously-visible reply on the wrong side of ``cut_idx``. The summariser then rolls it into the single ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block persisted as ``role="user"`` or ``role="assistant"``, and 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 actual answer: User: "i cant see the output of the last message you sent, i did see it previously, however now see 'context compaction'" Added ``_ensure_last_assistant_message_in_tail`` mirror of the user-side anchor. It looks for the most recent assistant message with non-empty text content (skipping tool-call-only assistant "stubs" which the UI renders as small "calling tool X" indicators rather than a readable bubble) and walks ``cut_idx`` back through the standard ``_align_boundary_backward`` so we don't split a tool_call/result group that immediately precedes it. The two anchors are chained — each only walks ``cut_idx`` backward, so the tail can only grow. Falls back to "most recent assistant of any kind" only when no content-bearing reply exists in the compressible region (fresh multi-step tool sequence with no prior reply) — in that case the agent-side fix is effectively a no-op and the existing user-message anchor carries the load. WebUI layer (clarity). Added ``isCompactionMessage`` detector that recognises the ``[CONTEXT COMPACTION — REFERENCE ONLY]`` (current) and ``[CONTEXT SUMMARY]:`` (legacy) prefixes from ``agent/context_compressor.py``, and a new ``compaction`` entry in ``MessageBubble``'s ``ROLE_STYLES`` map. Compaction blocks now render as muted, italicised system-style rows labelled ``Context handoff`` — clearly metadata, not the assistant's actual reply — so an operator scrolling back through a long session can't mistake the summary for a real answer. Keeping the detected prefixes inline (rather than importing them) because the WebUI bundle has no Python interop. A guardrail comment points readers at the source-of-truth constants in ``agent/context_compressor.py``. --- agent/context_compressor.py | 106 +++++++++++++++++++++++++++++++++ web/src/pages/SessionsPage.tsx | 47 +++++++++++++-- 2 files changed, 149 insertions(+), 4 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 57faf09d9d74..f6f0556e713b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1833,6 +1833,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]], @@ -1976,6 +2075,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) # ------------------------------------------------------------------ diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 34a68800d060..1701f80f82d1 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -147,6 +147,32 @@ 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 in sync with ``SUMMARY_PREFIX`` and +// ``LEGACY_SUMMARY_PREFIX`` in ``agent/context_compressor.py``. +const COMPACTION_PREFIXES = [ + "[CONTEXT COMPACTION — REFERENCE ONLY]", + "[CONTEXT COMPACTION - REFERENCE ONLY]", + "[CONTEXT SUMMARY]:", +] as const; + +function isCompactionMessage(msg: SessionMessage): boolean { + if (msg.role !== "user" && msg.role !== "assistant") return false; + const content = msg.content; + if (typeof content !== "string") return false; + const head = content.trimStart(); + return COMPACTION_PREFIXES.some((p) => head.startsWith(p)); +} + function MessageBubble({ msg, highlight, @@ -180,12 +206,25 @@ 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; + const isCompaction = isCompactionMessage(msg); + 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 = (() => { From 2fef3e2df2ce35a4b4e1a452b821dc9ebca2d3aa Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 21 May 2026 21:16:43 +0700 Subject: [PATCH 05/15] fix(webui): split merge-into-tail compaction so reply renders as its own bubble (#29824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compressor has a "double-collision" fallback path: when the chosen ``summary_role`` collides with the first tail message AND the flipped role would collide with the last head message, it can't emit a standalone summary turn (consecutive same-role messages break Anthropic and friends). It instead prepends the summary + end-of-summary marker to the first tail message's content via ``_merge_summary_into_tail``. With the matching anchor from the previous commit, that first tail message is now usually the user's previously-visible assistant reply — so the persisted assistant turn ends up shaped as ``[CONTEXT COMPACTION ...] ... --- END OF CONTEXT SUMMARY --- ... THE ACTUAL REPLY``. Without splitting it, the session viewer renders one big "Context handoff" bubble and the reply text is buried inside the metadata blob — which is exactly the "can't see the last reply" experience #29824 reports, just one layer deeper. Added ``splitCompactionContent`` that detects the merge marker (kept in sync with ``--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---`` in ``agent/context_compressor.py``) and ``MessageBubble`` now recurses on the two halves: the prefix half renders as the muted "Context handoff" row, the remainder half renders with the original assistant styling. Pure (non-merged) summary messages hit the no-remainder branch and still render as a single "Context handoff" row, preserving the original behaviour. --- web/src/pages/SessionsPage.tsx | 81 ++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 1701f80f82d1..c48d24538766 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -157,22 +157,50 @@ function ToolCallBlock({ // detect them here and downgrade them to a muted, clearly-labelled // "Context handoff" row. // -// Keep these prefixes in sync with ``SUMMARY_PREFIX`` and -// ``LEGACY_SUMMARY_PREFIX`` in ``agent/context_compressor.py``. +// Keep these prefixes (and the END marker below) in sync with +// ``SUMMARY_PREFIX`` / ``LEGACY_SUMMARY_PREFIX`` and the +// merge-into-tail marker in ``agent/context_compressor.py``. const COMPACTION_PREFIXES = [ "[CONTEXT COMPACTION — REFERENCE ONLY]", "[CONTEXT COMPACTION - REFERENCE ONLY]", "[CONTEXT SUMMARY]:", ] as const; -function isCompactionMessage(msg: SessionMessage): boolean { - if (msg.role !== "user" && msg.role !== "assistant") return false; - const content = msg.content; - if (typeof content !== "string") return false; - const head = content.trimStart(); - return COMPACTION_PREFIXES.some((p) => head.startsWith(p)); +// Marker the compressor inserts between a merged summary and the +// original tail message content. When the summary role would collide +// with both head and tail roles (e.g. head ends with ``user`` and tail +// starts with ``assistant``), the compressor merges the summary as a +// prefix on the first tail message instead of inserting a standalone +// row. We split on this marker so the WebUI still shows the original +// assistant reply as its own readable bubble — otherwise the merged +// row reads as a single opaque "Context compaction" block and the +// user can't see the reply (#29824). +const COMPACTION_END_MARKER = + "--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---"; + +interface CompactionSplit { + /** Summary text (header + body, without the end marker). */ + summary: string; + /** Original message content that came after the end marker. */ + remainder: string; } +function splitCompactionContent(content: string): CompactionSplit | null { + const head = content.trimStart(); + if (!COMPACTION_PREFIXES.some((p) => head.startsWith(p))) return null; + const markerIdx = content.indexOf(COMPACTION_END_MARKER); + if (markerIdx < 0) { + return { summary: content, remainder: "" }; + } + return { + summary: content.slice(0, markerIdx), + remainder: content + .slice(markerIdx + COMPACTION_END_MARKER.length) + .replace(/^\s+/, ""), + }; +} + + function MessageBubble({ msg, highlight, @@ -216,7 +244,42 @@ function MessageBubble({ }, }; - const isCompaction = isCompactionMessage(msg); + // When a compaction handoff is merged into the front of the first + // tail message (the compressor's double-collision path — + // ``_merge_summary_into_tail`` in ``agent/context_compressor.py``), + // the message we received is ``[CONTEXT COMPACTION ...] + END_MARKER + // + ``. We split it back into two visual + // rows here so the operator's actual answer survives as a readable + // bubble next to the (clearly-labelled) handoff metadata (#29824). + const compactionSplit = + typeof msg.content === "string" + ? splitCompactionContent(msg.content) + : null; + + if (compactionSplit && compactionSplit.remainder) { + return ( + <> + + + + ); + } + + const isCompaction = compactionSplit !== null; const style = isCompaction ? ROLE_STYLES.compaction : ROLE_STYLES[msg.role] ?? ROLE_STYLES.system; From 68536d4375f09eb87a4068b4c5f127573dcdafc9 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 21 May 2026 21:16:58 +0700 Subject: [PATCH 06/15] test(compressor): regression coverage for assistant-tail anchor + compaction rollup (#29824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 21 cases pinning the new ``_ensure_last_assistant_message_in_tail`` anchor and its interaction with the existing tail-cut path: * ``TestFindLastAssistantMessageIdx`` — helper contract: prefers a content-bearing assistant message, skips ``tool_calls``-only stubs, multimodal text-block content counts, falls back to "any assistant" when no content-bearing reply exists, honours ``head_end``, returns -1 when there's none. * ``TestEnsureLastAssistantMessageInTail`` — direct: no-op when already in the tail, walks ``cut_idx`` back when the reply is in the compressed middle, never crosses into the head region, re-aligns through a preceding ``tool_call`` / ``tool_result`` group instead of orphaning it. * ``TestFindTailCutByTokensAnchorsAssistant`` — integration: reporter repro (long tool-output run after the visible reply) now preserves the reply; user and assistant anchors compose in a single tail-cut call; a soft-ceiling-overrunning oversized tool result no longer strands the prior reply. * ``TestCompactionRollupReproduction`` — end-to-end through ``compress()`` with a stubbed ``_generate_summary``: the visible reply text survives either as its own standalone assistant message (normal path) or concatenated onto the merged summary tail (double-collision path the WebUI then re-splits). The standalone-summary case is asserted strictly (exactly one summary row, exactly one separate assistant row carrying the reply) — that's the dominant path and any drift there reintroduces the original bug. * ``TestSourceGuardrail`` — static asserts on ``agent/context_compressor.py``: the helper exists, the anchor is wired into ``_find_tail_cut_by_tokens`` AFTER the user-message anchor (so chaining is monotonic), the content-bearing preference is preserved, and the issue number is referenced so future bisects can find this fix. --- .../test_compressor_assistant_tail_anchor.py | 518 ++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 tests/agent/test_compressor_assistant_tail_anchor.py diff --git a/tests/agent/test_compressor_assistant_tail_anchor.py b/tests/agent/test_compressor_assistant_tail_anchor.py new file mode 100644 index 000000000000..e28bc82139f0 --- /dev/null +++ b/tests/agent/test_compressor_assistant_tail_anchor.py @@ -0,0 +1,518 @@ +"""Regression coverage for #29824 — the WebUI session viewer (and TUI +chat panel) was showing the ``[CONTEXT COMPACTION — REFERENCE ONLY]`` +handoff block in the slot where the user had just been reading the +assistant's actual reply, because the previously-visible reply got +rolled into the compaction summary by the token-budget tail walk. + +The fix adds ``_ensure_last_assistant_message_in_tail`` — a mirror of +the existing ``_ensure_last_user_message_in_tail`` (#10896 anchor) — +that pulls ``cut_idx`` back to include the most recent assistant +message with non-empty text content, with the standard tool-group +realignment so we don't orphan a ``tool_call`` / ``tool_result`` pair. + +Pinned here: + +* ``TestFindLastAssistantMessageIdx`` — pure helper contract: + finds the most recent **content-bearing** assistant message, + skips tool-call-only stubs, falls back to "any assistant" only + when no content-bearing reply exists in the compressible region, + honours ``head_end``, returns -1 when there's no assistant at all. + +* ``TestEnsureLastAssistantMessageInTail`` — direct: walks + ``cut_idx`` back when the last reply is in the compressed middle, + is a no-op when it's already in the tail, never crosses + ``head_end``, re-aligns through tool groups. + +* ``TestFindTailCutByTokensAnchorsAssistant`` — integration with + the existing tail-cut path: the exact reporter scenario (long + tool-output run after the previously-visible reply) preserves + the reply; combines with the user anchor for the same-turn + preservation; soft-ceiling overrun no longer hides the reply. + +* ``TestCompactionRollupReproduction`` — end-to-end through + ``compress()`` with a stubbed summariser: pre-fix the reply + text is absorbed into the summary (regression demonstrated by + asserting on the OLD behaviour fails); post-fix the reply text + is still present in the compressed transcript as a regular + assistant message. + +* ``TestSourceGuardrail`` — static asserts on + ``agent/context_compressor.py`` so a future refactor can't + silently drop the anchor. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +@pytest.fixture() +def compressor(): + """ContextCompressor with mocked deps and a tight tail budget so + the helpers' anchor behaviour is observable.""" + from agent.context_compressor import ContextCompressor + with patch( + "agent.context_compressor.get_model_context_length", + return_value=100_000, + ): + c = ContextCompressor( + model="test/model", + threshold_percent=0.85, + protect_first_n=2, + protect_last_n=2, + quiet_mode=True, + ) + c.tail_token_budget = 50 + return c + + +# --------------------------------------------------------------------------- +# Helper: _find_last_assistant_message_idx +# --------------------------------------------------------------------------- + + +class TestFindLastAssistantMessageIdx: + def test_finds_content_bearing_assistant(self, compressor): + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "q"}, + {"role": "assistant", "content": "the reply"}, + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=1) + assert idx == 2 + + def test_skips_tool_call_only_stub_when_text_reply_exists_earlier( + self, compressor + ): + """An assistant message that only carries ``tool_calls`` (no + text content) is not the user-visible reply — the WebUI + renders those as small "calling tool X" indicators. The helper + must prefer the earlier text reply, which is what the user + actually read.""" + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": "VISIBLE REPLY"}, + {"role": "user", "content": "q2"}, + {"role": "assistant", "content": None, + "tool_calls": [{"function": {"name": "t", + "arguments": "{}"}}]}, + {"role": "tool", "content": "result", "tool_call_id": "c1"}, + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=0) + assert idx == 1, ( + "Expected the content-bearing assistant reply (1), not the " + f"trailing tool-call stub. Got {idx}." + ) + + def test_empty_string_content_does_not_count_as_visible(self, compressor): + """An assistant message with ``content=""`` (only whitespace) + is not a visible reply either — common pre-flight stub before + the model streams the real answer.""" + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": "earlier reply"}, + {"role": "user", "content": "q2"}, + {"role": "assistant", "content": " "}, # blank stub + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=0) + # Blank-string assistant message does not count — fall back + # to the earlier real reply. + assert idx == 1 + + def test_multimodal_text_block_counts(self, compressor): + """An assistant with multimodal list-content carrying a text + block (Anthropic / GPT-style ``[{type:text,text:...}]``) + counts as content-bearing.""" + messages = [ + {"role": "user", "content": "q"}, + {"role": "assistant", + "content": [{"type": "text", "text": "hello"}]}, + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=0) + assert idx == 1 + + def test_fallback_to_any_assistant_when_no_content_bearing( + self, compressor + ): + """When there's no text-bearing assistant in the compressible + region (fresh multi-step tool sequence), fall back to the + most recent assistant of any kind so the anchor still works.""" + messages = [ + {"role": "user", "content": "q"}, + {"role": "assistant", "content": None, + "tool_calls": [{"function": {"name": "t", + "arguments": "{}"}}]}, + {"role": "tool", "content": "result", "tool_call_id": "c1"}, + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=0) + assert idx == 1 + + def test_returns_negative_one_when_no_assistant(self, compressor): + messages = [ + {"role": "user", "content": "q1"}, + {"role": "user", "content": "q2"}, + ] + idx = compressor._find_last_assistant_message_idx(messages, head_end=0) + assert idx == -1 + + def test_respects_head_end_lower_bound(self, compressor): + """An assistant message at or before ``head_end`` must be + ignored — it's already in the protected head region.""" + messages = [ + {"role": "system", "content": "sys"}, + {"role": "assistant", "content": "in-head reply"}, # idx 1 + {"role": "user", "content": "q"}, + ] + # head_end=2 means the compressible region starts at index 2; + # the assistant at index 1 is in the head and must be skipped. + idx = compressor._find_last_assistant_message_idx(messages, head_end=2) + assert idx == -1 + + +# --------------------------------------------------------------------------- +# Helper: _ensure_last_assistant_message_in_tail +# --------------------------------------------------------------------------- + + +class TestEnsureLastAssistantMessageInTail: + def test_no_op_when_already_in_tail(self, compressor): + messages = [ + {"role": "user", "content": "q"}, + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "q2"}, + ] + # cut_idx=1 means tail starts at index 1 — the reply is already in tail. + new_cut = compressor._ensure_last_assistant_message_in_tail( + messages, cut_idx=1, head_end=0 + ) + assert new_cut == 1 + + def test_walks_cut_idx_back_to_include_reply(self, compressor): + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": "REPLY"}, # idx 1 + {"role": "user", "content": "q2"}, + {"role": "user", "content": "q3"}, + ] + # cut_idx=2 leaves the reply outside the tail; anchor must pull + # cut_idx back to 1 so messages[1:] contains the reply. + new_cut = compressor._ensure_last_assistant_message_in_tail( + messages, cut_idx=2, head_end=0 + ) + assert new_cut == 1 + assert any( + isinstance(m.get("content"), str) and "REPLY" in m["content"] + for m in messages[new_cut:] + ) + + def test_never_crosses_head_end(self, compressor): + messages = [ + {"role": "system", "content": "sys"}, + {"role": "assistant", "content": "in-head"}, # head, must ignore + {"role": "user", "content": "q"}, + ] + # head_end=2 ⇒ assistant at idx 1 is in the head; the anchor + # finds nothing in the compressible region and is a no-op. + new_cut = compressor._ensure_last_assistant_message_in_tail( + messages, cut_idx=3, head_end=2 + ) + assert new_cut == 3 + + def test_re_aligns_through_preceding_tool_group(self, compressor): + """When the anchored assistant is preceded by a + tool_call/result group, ``_align_boundary_backward`` must pull + ``cut_idx`` even further back so the group isn't split — same + guarantee as ``_ensure_last_user_message_in_tail``.""" + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "c1", + "function": {"name": "t", + "arguments": "{}"}}]}, + {"role": "tool", "content": "result", "tool_call_id": "c1"}, + {"role": "assistant", "content": "REPLY"}, # idx 3 + {"role": "user", "content": "q2"}, + ] + # cut_idx=4 leaves the reply outside the tail. Anchor pulls + # back to 3, then _align_boundary_backward sees the preceding + # tool group and pulls further back to 1 (before the assistant + # with tool_calls). + new_cut = compressor._ensure_last_assistant_message_in_tail( + messages, cut_idx=4, head_end=0 + ) + assert new_cut <= 3 + # The tool_call assistant (1) and its tool_result (2) must NOT + # be split: either both in compressed region or both in tail. + if new_cut <= 1: + # Both in tail — tool group intact. + assert messages[new_cut].get("role") == "assistant" + else: + # Otherwise the anchor must land at the reply itself (3). + assert new_cut == 3 + + +# --------------------------------------------------------------------------- +# Integration with _find_tail_cut_by_tokens +# --------------------------------------------------------------------------- + + +class TestFindTailCutByTokensAnchorsAssistant: + def test_reporter_repro_long_tool_run_after_visible_reply( + self, compressor + ): + """The exact #29824 scenario: a tight token budget combined + with a long tail of tool-call/result messages after the + visible reply. Pre-fix, the token-budget walk hit its ceiling + on the tool output and parked ``cut_idx`` past the reply. + Post-fix, the assistant anchor pulls it back.""" + c = compressor + c.tail_token_budget = 10 # force min-tail behaviour + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "msg1"}, # head_end=2 + {"role": "user", "content": "q1"}, + {"role": "assistant", + "content": "PREVIOUSLY VISIBLE REPLY"}, # idx 3 + {"role": "user", "content": "q2"}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "c1", + "function": {"name": "t", + "arguments": "{}"}}]}, + {"role": "tool", "content": "x" * 200, + "tool_call_id": "c1"}, + ] + cut = c._find_tail_cut_by_tokens(messages, head_end=2) + tail_contents = [ + m.get("content") for m in messages[cut:] + if isinstance(m.get("content"), str) + ] + assert any( + "PREVIOUSLY VISIBLE REPLY" in (t or "") for t in tail_contents + ), ( + "REGRESSION (#29824): the visible reply was rolled into " + f"the compaction summary. Tail contents: {tail_contents!r}" + ) + + def test_user_and_assistant_anchors_compose(self, compressor): + """Both anchors run in sequence; the tail must contain both + the latest user message AND the latest visible assistant + reply.""" + c = compressor + c.tail_token_budget = 10 + messages = [ + {"role": "user", "content": "q1"}, + {"role": "assistant", "content": "VISIBLE REPLY"}, + {"role": "user", "content": "follow-up question"}, + {"role": "user", "content": "and another"}, + ] + cut = c._find_tail_cut_by_tokens(messages, head_end=0) + tail_contents = [ + m.get("content") for m in messages[cut:] + if isinstance(m.get("content"), str) + ] + assert any("VISIBLE REPLY" in (t or "") for t in tail_contents) + assert any("and another" in (t or "") for t in tail_contents) + + def test_oversized_tool_output_does_not_strand_reply(self, compressor): + """The soft-ceiling logic in ``_find_tail_cut_by_tokens`` + permits a single oversized tail message; the assistant anchor + must still recover the reply on the other side of it.""" + c = compressor + c.tail_token_budget = 100 # soft ceiling 150 + messages = [ + {"role": "user", "content": "earlier"}, + {"role": "assistant", "content": "VISIBLE REPLY"}, + {"role": "user", "content": "read big file"}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "c1", + "function": {"name": "read", + "arguments": "{}"}}]}, + # ~500 chars ⇒ ~135 tokens, blows past soft ceiling of 150 + {"role": "tool", "content": "y" * 500, + "tool_call_id": "c1"}, + {"role": "user", "content": "ok"}, + ] + cut = c._find_tail_cut_by_tokens(messages, head_end=0) + tail_contents = [ + m.get("content") for m in messages[cut:] + if isinstance(m.get("content"), str) + ] + assert any("VISIBLE REPLY" in (t or "") for t in tail_contents) + + +# --------------------------------------------------------------------------- +# End-to-end: compress() preserves the reply +# --------------------------------------------------------------------------- + + +class TestCompactionRollupReproduction: + """End-to-end through ``compress()``: the visible reply text must + survive in the compressed transcript — either as its own + standalone assistant message OR concatenated onto the merged + summary-handoff tail message (the compressor's double-collision + fallback path; the WebUI re-splits these on the END marker so the + reply renders as a separate bubble — see ``splitCompactionContent`` + in ``web/src/pages/SessionsPage.tsx``).""" + + def test_compress_keeps_visible_reply_text(self, compressor): + from agent.context_compressor import SUMMARY_PREFIX + c = compressor + c.tail_token_budget = 10 + # ``_generate_summary`` normally wraps the LLM body in + # ``SUMMARY_PREFIX`` via ``_with_summary_prefix``; mimic that so + # the merge-into-tail branch can identify the boundary. + _mocked = f"{SUMMARY_PREFIX}\nrolled-up middle summary" + messages = ( + [{"role": "system", "content": "sys"}, + {"role": "user", "content": "initial"}] # head (protect_first_n=2) + # Middle: long enough to be compressible. + + [ + {"role": "user", "content": f"middle q{i}"} + if i % 2 == 0 + else {"role": "assistant", "content": f"middle reply {i}"} + for i in range(12) + ] + + [ + {"role": "user", "content": "the visible question"}, + {"role": "assistant", + "content": "THE VISIBLE REPLY THE USER JUST READ"}, + {"role": "user", "content": "follow up"}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "c1", + "function": {"name": "t", + "arguments": "{}"}}]}, + {"role": "tool", "content": "z" * 500, + "tool_call_id": "c1"}, + ] + ) + with patch.object( + c, "_generate_summary", + return_value=_mocked, + ): + result = c.compress(messages, current_tokens=90_000) + # 1. A summary message exists (compression actually ran). + assert any( + isinstance(m.get("content"), str) + and m["content"].startswith(SUMMARY_PREFIX) + for m in result + ), "compress() did not insert a summary message" + # 2. The visible reply text must survive somewhere — either + # as its own message OR concatenated into the merged tail. + joined = "\n".join( + m.get("content") for m in result + if isinstance(m.get("content"), str) + ) + assert "THE VISIBLE REPLY THE USER JUST READ" in joined, ( + "REGRESSION (#29824): the visible reply was absorbed into " + "the compaction summary AND erased. Compressed transcript " + f"({len(result)} msgs): " + f"{[(m.get('role'), str(m.get('content'))[:50]) for m in result]}" + ) + + def test_standalone_summary_case_keeps_reply_as_own_message( + self, compressor + ): + """When the head and tail roles allow a standalone summary + message (no double-collision), the visible reply must remain + as its OWN assistant message — not merged with anything. + This is the common case; the merge-into-tail path is the + edge case for double-collision.""" + from agent.context_compressor import SUMMARY_PREFIX + c = compressor + c.tail_token_budget = 10 + _mocked = f"{SUMMARY_PREFIX}\nrolled-up middle summary" + # Head ends with ``assistant`` ⇒ summary_role flips to + # ``user`` ⇒ no collision with the assistant tail ⇒ standalone + # summary insert (no merge). + messages = ( + [ + {"role": "user", "content": "initial"}, + {"role": "assistant", "content": "head reply"}, + ] + + [ + {"role": "user", "content": f"middle q{i}"} + if i % 2 == 0 + else {"role": "assistant", "content": f"middle reply {i}"} + for i in range(12) + ] + + [ + {"role": "user", "content": "the visible question"}, + {"role": "assistant", + "content": "THE VISIBLE REPLY THE USER JUST READ"}, + {"role": "user", "content": "follow up"}, + ] + ) + with patch.object( + c, "_generate_summary", + return_value=_mocked, + ): + result = c.compress(messages, current_tokens=90_000) + # Standalone summary present: + summary_rows = [ + m for m in result + if isinstance(m.get("content"), str) + and m["content"].startswith(SUMMARY_PREFIX) + ] + assert len(summary_rows) == 1 + # Visible reply as its OWN distinct assistant message + # (NOT merged into the summary row): + reply_rows = [ + m for m in result + if m.get("role") == "assistant" + and isinstance(m.get("content"), str) + and "THE VISIBLE REPLY THE USER JUST READ" in m["content"] + and not m["content"].startswith(SUMMARY_PREFIX) + ] + assert len(reply_rows) == 1, ( + "REGRESSION (#29824): expected exactly one standalone " + f"assistant message carrying the visible reply, got " + f"{len(reply_rows)}" + ) + + +# --------------------------------------------------------------------------- +# Source guardrail +# --------------------------------------------------------------------------- + + +class TestSourceGuardrail: + @pytest.fixture + def source(self) -> str: + from pathlib import Path + return (Path(__file__).resolve().parents[2] + / "agent" / "context_compressor.py").read_text( + encoding="utf-8") + + def test_helper_defined(self, source): + assert "def _find_last_assistant_message_idx(" in source + assert "def _ensure_last_assistant_message_in_tail(" in source + + def test_anchor_called_from_find_tail_cut(self, source): + """Without the call site the helper is dead code and the bug + regresses silently — pin both the definition AND the wiring.""" + assert "self._ensure_last_assistant_message_in_tail(" in source + + def test_anchor_called_after_user_anchor(self, source): + """The two anchors must run in sequence; reversing or skipping + one drops the corresponding side of the guarantee.""" + user_call = "self._ensure_last_user_message_in_tail(messages, cut_idx, head_end)" + asst_call = "self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end)" + user_idx = source.find(user_call) + asst_idx = source.find(asst_call) + assert user_idx >= 0 and asst_idx >= 0 + assert asst_idx > user_idx, ( + "The assistant anchor must come AFTER the user anchor in " + "``_find_tail_cut_by_tokens`` — each anchor walks cut_idx " + "backward, and ordering keeps the chain monotonic." + ) + + def test_helper_prefers_content_bearing_reply(self, source): + """The helper must skip tool-call-only stubs — that's the + whole user-experience difference between #29824 (no visible + reply) and an in-progress turn (small 'calling tool X' chip).""" + assert "content.strip()" in source + + def test_issue_number_referenced(self, source): + assert "#29824" in source From 135fe90166e8dd54739c9756eea3074832db826c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:42:14 -0700 Subject: [PATCH 07/15] fix(profiles): backfill .env for pre-existing profiles on hermes update (#45247) Profiles created before #44792 have no .env. Now that the Channels/Keys endpoints are profile-scoped (no os.environ fallback), those profiles would show everything as unconfigured. hermes update now copies the default install's .env into each named profile that lacks one (0600, never overwrites, placeholder fallback when the root has no .env), so existing users keep the credentials they were effectively running with. --- hermes_cli/main.py | 16 +++++++++ hermes_cli/profiles.py | 52 +++++++++++++++++++++++++++++ tests/hermes_cli/test_profiles.py | 55 +++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 841d8bc947c5..f2ad89f7601c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8750,6 +8750,22 @@ def _cmd_update_impl(args, gateway_mode: bool): except Exception: pass # profiles module not available or no profiles + # Backfill per-profile .env files for profiles created before the + # .env-seeding fix (#44792). Copies the default install's .env so + # those profiles keep the credentials they were effectively using. + try: + from hermes_cli.profiles import backfill_profile_envs + + backfilled = backfill_profile_envs(quiet=True) + if backfilled: + print() + print( + f"→ Seeded .env for {len(backfilled)} profile(s) " + f"(copied from default): {', '.join(backfilled)}" + ) + except Exception: + pass # profiles module not available or no profiles + # Sync Honcho host blocks to all profiles try: from plugins.memory.honcho.cli import sync_honcho_profiles_quiet diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index f30eb70650e1..50e5bbeabbc9 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -977,6 +977,58 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict return None +def backfill_profile_envs(quiet: bool = False) -> List[str]: + """Give every named profile that predates per-profile ``.env`` files one. + + Profiles created before the dashboard/CLI started seeding a ``.env`` + (PR #44792) have none, so once the Channels/Keys endpoints became + profile-scoped those profiles stopped inheriting the root install's + credentials and showed everything as unconfigured. To avoid breaking + anyone on update, copy the DEFAULT install's ``.env`` into each named + profile that lacks one — that preserves the effective credentials those + profiles were already running with (they previously read the root + ``.env`` via the process environment). Users can then diverge per + profile from there. + + Falls back to the placeholder header when the default install has no + ``.env`` itself. Never overwrites an existing profile ``.env``. + + Returns the list of profile names that received a backfilled ``.env``. + """ + backfilled: List[str] = [] + profiles_root = _get_profiles_root() + if not profiles_root.is_dir(): + return backfilled + + default_env = _get_default_hermes_home() / ".env" + + for entry in sorted(profiles_root.iterdir()): + if not entry.is_dir() or not _PROFILE_ID_RE.match(entry.name): + continue + if entry.name == "default": + continue + env_path = entry / ".env" + if env_path.exists(): + continue + try: + if default_env.is_file(): + shutil.copy2(default_env, env_path) + else: + env_path.write_text( + "# Per-profile secrets for this Hermes profile.\n" + "# API keys and tokens set here override the shell environment.\n" + "# Behavioral settings belong in config.yaml, not here.\n", + encoding="utf-8", + ) + os.chmod(str(env_path), 0o600) + backfilled.append(entry.name) + except OSError as e: + if not quiet: + print(f"⚠ Could not seed .env for profile '{entry.name}': {e}") + + return backfilled + + def delete_profile(name: str, yes: bool = False) -> Path: """Delete a profile, its wrapper script, and its gateway service. diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index c38b1f0655dc..2a23b648baa7 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -33,6 +33,7 @@ from hermes_cli.profiles import ( seed_profile_skills, has_bundled_skills_opt_out, NO_BUNDLED_SKILLS_MARKER, + backfill_profile_envs, ) @@ -473,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 # =================================================================== From bbf020e709eca4571c488ebb7cc65b1202bf5dab Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 12 Jun 2026 18:00:11 -0500 Subject: [PATCH 08/15] feat(desktop): follow streaming output at bottom + jump-to-bottom button (#45263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strict sticky-bottom autoscroll for the chat thread: while the viewport is parked at the bottom, the tail follows content growth (streaming tokens, late measurement, Shiki re-highlight) via a useLayoutEffect keyed on the virtualizer's own size signal, pinned in the same pre-paint pass as its scrollToFn so the two never rubber-band. The gate is a single boolean — one upward pixel (scroll/wheel/touch) disarms follow until the user returns to the bottom. Adds a floating jump-to-bottom control that appears once scrolled ~10px away (above the dim threshold so a sub-pixel settle never flashes it), positioned above the composer with respect to the status stack, with a subtle scale + slide in/out animation that honours prefers-reduced-motion. The button bridges to the virtualizer's re-arm + pin path through a small nanostore emitter. Supersedes #43624. --- apps/desktop/src/app/chat/index.tsx | 2 + .../src/app/chat/scroll-to-bottom-button.tsx | 58 +++++++++++++++ .../assistant-ui/thread-virtualizer.tsx | 72 +++++++++++++------ apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/store/thread-scroll.ts | 36 ++++++++-- apps/desktop/src/styles.css | 57 ++++++++++++++- 8 files changed, 198 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/app/chat/scroll-to-bottom-button.tsx diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b296072d131f..99adf73559fc 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -53,6 +53,7 @@ import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from import type { ChatBarState } from './composer/types' import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions' import { useFileDropZone } from './hooks/use-file-drop-zone' +import { ScrollToBottomButton } from './scroll-to-bottom-button' import { SessionActionsMenu } from './sidebar/session-actions-menu' import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading' @@ -391,6 +392,7 @@ export function ChatView({ )} + {showChatBar && } diff --git a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx new file mode 100644 index 000000000000..b5d947ac18cf --- /dev/null +++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx @@ -0,0 +1,58 @@ +import { useStore } from '@nanostores/react' +import { useRef } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { cn } from '@/lib/utils' +import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll' + +/** + * Floating "jump to bottom" control. Sits centered just above the composer, + * clearing the out-of-flow status stack via the same measured-height CSS vars + * the thread's bottom clearance uses (`--composer-measured-height` + + * `--status-stack-measured-height`), so it never overlaps the queue / subagent + * / background cards. Visible only while the user has scrolled meaningfully + * away from the bottom; clicking re-arms sticky-bottom and pins the viewport. + * + * Enter/exit motion lives in styles.css under `.thread-jump-button` — a + * directional scale (contract in from 1.1, contract out to 0.9) keyed off + * `data-state`. `idle` (never-shown) stays silent so it can't flash on mount; + * `in`/`out` only swap once it has actually appeared. + */ +export function ScrollToBottomButton() { + const { t } = useI18n() + const visible = useStore($threadJumpButtonVisible) + const hasShownRef = useRef(false) + + if (visible) { + hasShownRef.current = true + } + + const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle' + + return ( + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx index 0b3a5c824afe..c42084138493 100644 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -14,13 +14,21 @@ import { import { setMutableRef } from '@/lib/mutable-ref' import { cn } from '@/lib/utils' -import { setThreadScrolledUp } from '@/store/thread-scroll' +import { + onScrollToBottomRequest, + resetThreadScroll, + setThreadJumpButtonVisible, + setThreadScrolledUp +} from '@/store/thread-scroll' import { MessageRenderBoundary } from './message-render-boundary' const ESTIMATED_ITEM_HEIGHT = 220 const OVERSCAN = 4 const AT_BOTTOM_THRESHOLD = 4 +// Reveal the floating jump button only once scrolled meaningfully away — above +// AT_BOTTOM_THRESHOLD so a sub-pixel settle never flashes it. +const JUMP_BUTTON_THRESHOLD = 10 type ThreadMessageComponents = ComponentProps['components'] @@ -309,7 +317,7 @@ function useThreadScrollAnchor({ }) }, [groupCount, pinToBottom, stickyBottomRef, virtualizer]) - useEffect(() => () => setThreadScrolledUp(false), []) + useEffect(() => () => resetThreadScroll(), []) // Track at-bottom state, dim composer when scrolled up, disarm on user // scroll/wheel/touch. @@ -325,6 +333,13 @@ function useThreadScrollAnchor({ programmaticScrollPendingRef.current = 0 } + // Dim the composer the instant we leave the bottom; reveal the jump button + // only once scrolled meaningfully away. + const publishScrollDistance = (dist: number) => { + setThreadScrolledUp(dist > AT_BOTTOM_THRESHOLD) + setThreadJumpButtonVisible(dist > JUMP_BUTTON_THRESHOLD) + } + const onScroll = () => { const top = el.scrollTop @@ -342,22 +357,19 @@ function useThreadScrollAnchor({ lastClientHeightRef.current = el.clientHeight // Always re-arm — sticky-bottom should hold through clamp races. setMutableRef(stickyBottomRef, true) - const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD - setThreadScrolledUp(!atBottom) + publishScrollDistance(el.scrollHeight - (top + el.clientHeight)) return } - // Disarm only when `scrollTop` decreases while both content height and - // viewport height are stable. A bare `top < lastTopRef.current` check is - // unsafe: virtualizer measurement, streaming markdown, composer resizing, - // window resizing, and toolbar/status updates can all move scrollTop as a - // layout side effect. Wheel-up and touchmove still disarm immediately via - // their own listeners below, so real user intent remains covered. + // Disarm on ANY upward movement (even 1px), but only while content + + // viewport height are stable — virtualizer measurement, streaming + // markdown, and composer/window resize all shift scrollTop as a layout + // side effect. Wheel-up and touchmove disarm immediately too (below). const heightGrew = el.scrollHeight > lastHeightRef.current const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1 - if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) { + if (!heightGrew && !clientHeightChanged && top < lastTopRef.current) { setMutableRef(stickyBottomRef, false) } @@ -365,13 +377,14 @@ function useThreadScrollAnchor({ lastHeightRef.current = el.scrollHeight lastClientHeightRef.current = el.clientHeight - const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD + const distFromBottom = el.scrollHeight - (top + el.clientHeight) - if (atBottom) { + // Re-arm follow only once genuinely back at the bottom. + if (distFromBottom <= AT_BOTTOM_THRESHOLD) { setMutableRef(stickyBottomRef, true) } - setThreadScrolledUp(!atBottom) + publishScrollDistance(distFromBottom) } const onWheel = (event: WheelEvent) => { @@ -391,15 +404,28 @@ function useThreadScrollAnchor({ } }, [scrollerRef, stickyBottomRef]) - // Intentionally NO streaming auto-follow. Earlier builds ran a - // ResizeObserver here that re-pinned the viewport to the bottom on every - // content growth while a turn was running, so the chat tracked tokens as - // they streamed. That behavior is removed by request: once a turn is in - // flight the viewport stays exactly where the user left it. The viewport - // is still moved to the bottom ONCE per user submit / new turn / session - // change (see the layout effect and the session-change effect below) so a - // freshly submitted message lands in view — but it does not chase the - // stream afterward. + // Streaming auto-follow: while — and ONLY while — parked at the bottom, chase + // content growth (streaming tokens, late measurement, Shiki re-highlight) so + // the tail stays in view. One upward pixel (scroll/wheel/touch above) flips + // the gate false and following stops until the user returns to the bottom. + // Keyed on the virtualizer's own size signal and pinned in useLayoutEffect — + // the virtualizer's scrollToFn runs in the same pre-paint pass, so the two + // don't fight (no rubber-banding). pinToBottom no-ops at bottom, so rapid + // growth is cheap. + const totalSize = virtualizer.getTotalSize() + const prevTotalSizeRef = useRef(null) + useLayoutEffect(() => { + const prev = prevTotalSizeRef.current + prevTotalSizeRef.current = totalSize + + if (enabled && prev !== null && totalSize > prev && stickyBottomRef.current) { + pinToBottom() + } + }, [enabled, pinToBottom, stickyBottomRef, totalSize]) + + // The floating jump button asks us to return to the bottom; same re-arm + pin + // path as a new turn. + useEffect(() => onScrollToBottomRequest(jumpToBottom), [jumpToBottom]) // Jump to bottom on session change OR when an empty thread first gets // content. Both share the same intent and the same effect. diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 4542b0d02f42..3e9671e21979 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1666,6 +1666,7 @@ export const en: Translations = { stopReading: 'Stop reading', readAloud: 'Read aloud', editMessage: 'Edit message', + scrollToBottom: 'Scroll to bottom', stop: 'Stop', restorePrevious: 'Restore previous checkpoint', restoreCheckpoint: 'Restore checkpoint', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 52a9a1692db5..7b75afa57453 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1325,6 +1325,7 @@ export interface Translations { stopReading: string readAloud: string editMessage: string + scrollToBottom: string stop: string restorePrevious: string restoreCheckpoint: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 67c86c52cba7..9dbe863714af 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1846,6 +1846,7 @@ export const zh: Translations = { stopReading: '停止朗读', readAloud: '朗读', editMessage: '编辑消息', + scrollToBottom: '滚动到底部', stop: '停止', restorePrevious: '恢复上一个检查点', restoreCheckpoint: '恢复检查点', diff --git a/apps/desktop/src/store/thread-scroll.ts b/apps/desktop/src/store/thread-scroll.ts index b577f50404de..c0a9afd741fd 100644 --- a/apps/desktop/src/store/thread-scroll.ts +++ b/apps/desktop/src/store/thread-scroll.ts @@ -1,11 +1,35 @@ -import { atom } from 'nanostores' +import { atom, type WritableAtom } from 'nanostores' +// `$threadScrolledUp` flips the instant the viewport leaves the bottom (dims the +// composer / status stack). `$threadJumpButtonVisible` trips a little further up +// (~10px) so the floating jump control only shows once meaningfully away. export const $threadScrolledUp = atom(false) +export const $threadJumpButtonVisible = atom(false) -export function setThreadScrolledUp(value: boolean) { - if ($threadScrolledUp.get() === value) { - return +// Skip no-op writes so subscribers don't churn on every scroll tick. +const setter = (target: WritableAtom) => (value: boolean) => { + if (target.get() !== value) { + target.set(value) } - - $threadScrolledUp.set(value) } + +export const setThreadScrolledUp = setter($threadScrolledUp) +export const setThreadJumpButtonVisible = setter($threadJumpButtonVisible) + +export const resetThreadScroll = () => { + setThreadScrolledUp(false) + setThreadJumpButtonVisible(false) +} + +// Cross-component bridge: the jump button lives by the composer, the re-arm + +// pin machinery lives in the virtualizer. The virtualizer registers a handler; +// the button fires it. Mirrors the composer focus/insert emitter pattern. +const handlers = new Set<() => void>() + +export const onScrollToBottomRequest = (handler: () => void) => { + handlers.add(handler) + + return () => void handlers.delete(handler) +} + +export const requestScrollToBottom = () => handlers.forEach(handler => handler()) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 42b781eb3cf5..b4a4d33f03f1 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1255,6 +1255,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)); @@ -1276,4 +1332,3 @@ canvas { animation: none; } } - From aec38855b5792e4a912d527b64df1a43cbf09c90 Mon Sep 17 00:00:00 2001 From: konsisumer Date: Fri, 12 Jun 2026 09:27:58 +0200 Subject: [PATCH 09/15] fix(agent): preserve recent turns during compression --- agent/context_compressor.py | 31 +++++++++++++++++------ tests/agent/test_context_compressor.py | 34 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index f6f0556e713b..16789229dbac 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -156,6 +156,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`'\")\]}<>]+") @@ -1990,11 +1995,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``). @@ -2002,8 +2008,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 diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index b121192bd170..7eb1e8a57b02 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -1804,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.""" From 5d0408d9fe07e0182de562d8d7f795aac07f2798 Mon Sep 17 00:00:00 2001 From: kyssta-exe Date: Fri, 12 Jun 2026 15:06:26 +0000 Subject: [PATCH 10/15] fix(agent): clamp flush cursor after repair_message_sequence compaction (#44837) --- agent/conversation_loop.py | 8 ++++++++ run_agent.py | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index bcd84a373bbe..3a687fe71385 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -602,6 +602,14 @@ def run_conversation( repaired_seq, agent.session_id or "-", ) + # Clamp the SessionDB flush cursor after compaction. If repair + # merged or dropped messages, _last_flushed_db_idx may now point + # past the new end of `messages`, causing turn-end flush to skip + # the assistant/tool chain entirely (#44837). + if hasattr(agent, "_last_flushed_db_idx"): + agent._last_flushed_db_idx = min( + agent._last_flushed_db_idx, len(messages) + ) api_messages = [] for idx, msg in enumerate(messages): diff --git a/run_agent.py b/run_agent.py index 8026a602c685..0be8b1763fa4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1560,7 +1560,12 @@ class AIAgent: if not self._session_db_created: self._ensure_db_session() start_idx = len(conversation_history) if conversation_history else 0 - flush_from = max(start_idx, self._last_flushed_db_idx) + # Guard against the flush cursor overshooting the message list. + # This can happen when repair_message_sequence compacts the list + # (merging consecutive users, dropping stray tools) after the + # cursor was set. Fall back to start_idx so we don't skip + # persisting the assistant/tool chain (#44837). + flush_from = max(start_idx, min(self._last_flushed_db_idx, len(messages))) for msg in messages[flush_from:]: role = msg.get("role", "unknown") content = msg.get("content") From 8905ee6b8a28ecca714776a94f563ac6da912e3b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:47:34 -0700 Subject: [PATCH 11/15] fix(agent): rewind flush cursor exactly when repair compacts before the cursor Follow-up to the #44837 clamp: a min() clamp only fixes cursor overshoot past the new end of the list. When repair_message_sequence drops/merges messages at indexes below the cursor, the clamp leaves the cursor pointing past unflushed rows and the turn-end flush silently skips them. Extract repair_message_sequence_with_cursor(): snapshot the flushed prefix by object identity before repair, then recompute the cursor as the count of surviving flushed messages. Falls back to the clamp when no snapshot is available. Keeps the safety guard in _flush_messages_to_session_db. Adds targeted tests for overshoot, before-cursor compaction, no-repair, bare-agent, and the flush guard. --- agent/agent_runtime_helpers.py | 39 ++++++++ agent/conversation_loop.py | 14 +-- .../run_agent/test_message_sequence_repair.py | 99 +++++++++++++++++++ 3 files changed, 143 insertions(+), 9 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 742af1453807..f144b3f67a63 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -445,6 +445,45 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: return repairs +def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int: + """Run :func:`repair_message_sequence` and keep the SessionDB flush + cursor consistent with the compacted list (#44837). + + ``repair_message_sequence`` merges/drops messages in place, shrinking + the list. ``_last_flushed_db_idx`` (the DB-write cursor) indexes into + that list, so after compaction it can point past the new end — the + turn-end flush would then skip the assistant/tool chain entirely — or + past unflushed messages shifted to lower indexes. + + Repair preserves object identity for surviving messages, so counting + the survivors from the previously-flushed prefix gives the exact new + cursor even when messages are dropped/merged at indexes *before* the + cursor — a plain ``min()`` clamp would silently skip that many + unflushed rows. Falls back to the clamp when no prefix snapshot is + available. + + Returns the number of repairs made (same as ``repair_message_sequence``). + """ + pre_repair_flushed_ids = None + flush_cursor = getattr(agent, "_last_flushed_db_idx", None) + if isinstance(flush_cursor, int) and flush_cursor > 0: + pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]} + + repairs = repair_message_sequence(agent, messages) + + if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"): + if pre_repair_flushed_ids is not None: + agent._last_flushed_db_idx = sum( + 1 for m in messages if id(m) in pre_repair_flushed_ids + ) + else: + agent._last_flushed_db_idx = min( + agent._last_flushed_db_idx, len(messages) + ) + + return repairs + + def strip_think_blocks(agent, content: str) -> str: """Remove reasoning/thinking blocks from content, returning only visible text. diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 3a687fe71385..05d19772d9ca 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -595,21 +595,17 @@ 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)", repaired_seq, agent.session_id or "-", ) - # Clamp the SessionDB flush cursor after compaction. If repair - # merged or dropped messages, _last_flushed_db_idx may now point - # past the new end of `messages`, causing turn-end flush to skip - # the assistant/tool chain entirely (#44837). - if hasattr(agent, "_last_flushed_db_idx"): - agent._last_flushed_db_idx = min( - agent._last_flushed_db_idx, len(messages) - ) api_messages = [] for idx, msg in enumerate(messages): diff --git a/tests/run_agent/test_message_sequence_repair.py b/tests/run_agent/test_message_sequence_repair.py index fd1db95e8436..8fba45ebeb2a 100644 --- a/tests/run_agent/test_message_sequence_repair.py +++ b/tests/run_agent/test_message_sequence_repair.py @@ -199,3 +199,102 @@ def test_repair_preserves_system_messages(): AIAgent._repair_message_sequence(agent, messages) assert messages == original + + +# ── repair_message_sequence_with_cursor (#44837) ─────────────────────────── + +from agent.agent_runtime_helpers import repair_message_sequence_with_cursor + + +def test_cursor_clamped_when_compaction_shrinks_below_cursor(): + """Cursor past the new end of the list must come back in range so the + turn-end flush doesn't skip the assistant/tool chain (#44837).""" + agent = _bare_agent() + messages = [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "second"}, + ] + agent._last_flushed_db_idx = 2 # both rows already flushed + + repairs = repair_message_sequence_with_cursor(agent, messages) + + assert repairs == 1 + assert len(messages) == 1 + assert agent._last_flushed_db_idx == 1 + + +def test_cursor_rewinds_when_compaction_happens_before_cursor(): + """Repair that drops/merges messages at indexes BELOW the cursor must + rewind it by the number removed, or unflushed rows get skipped. + A plain min() clamp does NOT catch this case.""" + agent = _bare_agent() + flushed_a = {"role": "user", "content": "first"} + flushed_b = {"role": "user", "content": "second"} # merged into flushed_a + unflushed_assistant = {"role": "assistant", "content": "answer"} + messages = [flushed_a, flushed_b, unflushed_assistant] + agent._last_flushed_db_idx = 2 # the two user rows are flushed + + repairs = repair_message_sequence_with_cursor(agent, messages) + + assert repairs == 1 + assert len(messages) == 2 + # Cursor must now point at the assistant (index 1), not stay at 2 — + # min(2, len=2) would leave it at 2 and the flush would skip it. + assert agent._last_flushed_db_idx == 1 + assert messages[agent._last_flushed_db_idx] is unflushed_assistant + + +def test_cursor_untouched_when_no_repairs(): + agent = _bare_agent() + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + agent._last_flushed_db_idx = 1 + + repairs = repair_message_sequence_with_cursor(agent, messages) + + assert repairs == 0 + assert agent._last_flushed_db_idx == 1 + + +def test_cursor_helper_safe_without_cursor_attribute(): + """Bare agents (no _last_flushed_db_idx) must not crash.""" + agent = _bare_agent() + messages = [ + {"role": "user", "content": "a"}, + {"role": "user", "content": "b"}, + ] + + repairs = repair_message_sequence_with_cursor(agent, messages) + + assert repairs == 1 + assert not hasattr(agent, "_last_flushed_db_idx") + + +def test_flush_guard_clamps_overshooting_cursor(): + """_flush_messages_to_session_db safety net: an overshooting cursor must + not produce a negative-start slice that skips everything (#44837).""" + + class _DB: + def __init__(self): + self.rows = [] + + def append_message(self, **kw): + self.rows.append(kw) + + agent = _bare_agent() + agent._session_db = _DB() + agent._session_db_created = True + agent.session_id = "s1" + agent._persist_user_message_override = None + agent._last_flushed_db_idx = 5 # stale — past end of compacted list + messages = [ + {"role": "user", "content": "q"}, + {"role": "assistant", "content": "a"}, + ] + + AIAgent._flush_messages_to_session_db(agent, messages, conversation_history=[]) + + # min(5, 2) = 2 → nothing skipped below start_idx, cursor settles at 2 + assert agent._last_flushed_db_idx == 2 From 1899c8f507c34338d3c66493cffd7d10ba705a8d Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:52:09 -0600 Subject: [PATCH 12/15] fix(skills): run youtube transcript helper through uv --- skills/media/youtube-content/SKILL.md | 17 ++++++++++------- .../youtube-content/scripts/fetch_transcript.py | 6 +++--- .../bundled/media/media-youtube-content.md | 17 ++++++++++------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/skills/media/youtube-content/SKILL.md b/skills/media/youtube-content/SKILL.md index 32828f75986b..3661acad126a 100644 --- a/skills/media/youtube-content/SKILL.md +++ b/skills/media/youtube-content/SKILL.md @@ -14,8 +14,11 @@ Extract transcripts from YouTube videos and convert them into useful formats. ## Setup +Use `uv` so the dependency is installed into the same Hermes-managed environment +that runs the helper script: + ```bash -pip install youtube-transcript-api +uv pip install youtube-transcript-api ``` ## Helper Script @@ -24,16 +27,16 @@ pip install youtube-transcript-api ```bash # JSON output with metadata -python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" # Plain text (good for piping into further processing) -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only # With timestamps -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps # Specific language with fallback chain -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en ``` ## Output Formats @@ -59,7 +62,7 @@ After fetching the transcript, format it based on what the user asks for: ## Workflow -1. **Fetch** the transcript using the helper script with `--text-only --timestamps`. +1. **Fetch** the transcript using the helper script with `--text-only --timestamps` via `uv run python3`. 2. **Validate**: confirm the output is non-empty and in the expected language. If empty, retry without `--language` to get any available transcript. If still empty, tell the user the video likely has transcripts disabled. 3. **Chunk if needed**: if the transcript exceeds ~50K characters, split into overlapping chunks (~40K with 2K overlap) and summarize each chunk before merging. 4. **Transform** into the requested output format. If the user did not specify a format, default to a summary. @@ -70,4 +73,4 @@ After fetching the transcript, format it based on what the user asks for: - **Transcript disabled**: tell the user; suggest they check if subtitles are available on the video page. - **Private/unavailable video**: relay the error and ask the user to verify the URL. - **No matching language**: retry without `--language` to fetch any available transcript, then note the actual language to the user. -- **Dependency missing**: run `pip install youtube-transcript-api` and retry. +- **Dependency missing**: run `uv pip install youtube-transcript-api` and retry. diff --git a/skills/media/youtube-content/scripts/fetch_transcript.py b/skills/media/youtube-content/scripts/fetch_transcript.py index 5ad3e5aa652b..6160339038dd 100644 --- a/skills/media/youtube-content/scripts/fetch_transcript.py +++ b/skills/media/youtube-content/scripts/fetch_transcript.py @@ -3,7 +3,7 @@ Fetch a YouTube video transcript and output it as structured JSON. Usage: - python fetch_transcript.py [--language en,tr] [--timestamps] + uv run python3 fetch_transcript.py [--language en,tr] [--timestamps] Output (JSON): { @@ -14,7 +14,7 @@ Output (JSON): "timestamped_text": "00:00 first line\n00:05 second line\n..." } -Install dependency: pip install youtube-transcript-api +Install dependency: uv pip install youtube-transcript-api """ import argparse @@ -56,7 +56,7 @@ def fetch_transcript(video_id: str, languages: list = None): try: from youtube_transcript_api import YouTubeTranscriptApi except ImportError: - print("Error: youtube-transcript-api not installed. Run: pip install youtube-transcript-api", + print("Error: youtube-transcript-api not installed. Run: uv pip install youtube-transcript-api", file=sys.stderr) sys.exit(1) diff --git a/website/docs/user-guide/skills/bundled/media/media-youtube-content.md b/website/docs/user-guide/skills/bundled/media/media-youtube-content.md index 24f8871a9724..2971a56f9551 100644 --- a/website/docs/user-guide/skills/bundled/media/media-youtube-content.md +++ b/website/docs/user-guide/skills/bundled/media/media-youtube-content.md @@ -34,8 +34,11 @@ Extract transcripts from YouTube videos and convert them into useful formats. ## Setup +Use `uv` so the dependency is installed into the same Hermes-managed environment +that runs the helper script: + ```bash -pip install youtube-transcript-api +uv pip install youtube-transcript-api ``` ## Helper Script @@ -44,16 +47,16 @@ pip install youtube-transcript-api ```bash # JSON output with metadata -python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "https://youtube.com/watch?v=VIDEO_ID" # Plain text (good for piping into further processing) -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --text-only # With timestamps -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --timestamps # Specific language with fallback chain -python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en +uv run python3 SKILL_DIR/scripts/fetch_transcript.py "URL" --language tr,en ``` ## Output Formats @@ -79,7 +82,7 @@ After fetching the transcript, format it based on what the user asks for: ## Workflow -1. **Fetch** the transcript using the helper script with `--text-only --timestamps`. +1. **Fetch** the transcript using the helper script with `--text-only --timestamps` via `uv run python3`. 2. **Validate**: confirm the output is non-empty and in the expected language. If empty, retry without `--language` to get any available transcript. If still empty, tell the user the video likely has transcripts disabled. 3. **Chunk if needed**: if the transcript exceeds ~50K characters, split into overlapping chunks (~40K with 2K overlap) and summarize each chunk before merging. 4. **Transform** into the requested output format. If the user did not specify a format, default to a summary. @@ -90,4 +93,4 @@ After fetching the transcript, format it based on what the user asks for: - **Transcript disabled**: tell the user; suggest they check if subtitles are available on the video page. - **Private/unavailable video**: relay the error and ask the user to verify the URL. - **No matching language**: retry without `--language` to fetch any available transcript, then note the actual language to the user. -- **Dependency missing**: run `pip install youtube-transcript-api` and retry. +- **Dependency missing**: run `uv pip install youtube-transcript-api` and retry. From 956af7f3c31118277d458a72529e61da6ddc422a Mon Sep 17 00:00:00 2001 From: kyssta-exe Date: Fri, 12 Jun 2026 16:32:52 -0700 Subject: [PATCH 13/15] fix(agent): add metadata flag to context compression summary messages (#38389) Summary messages (standalone insertion and merge-into-tail) now carry a metadata flag so frontends (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user messages without content-prefix heuristics. Re-applied from PR #38434 onto current main (conflicted with the _SUMMARY_END_MARKER hoist). Key renamed from the PR's 'is_compressed_summary' to '_compressed_summary': the wire sanitizers strip underscore-prefixed message keys, so the flag stays in-process and can never reach strict gateways (Fireworks/Mistral/Kimi reject unknown keys with 'Extra inputs are not permitted'). --- agent/context_compressor.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 16789229dbac..16db1bedc30f 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -69,6 +69,21 @@ 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 @@ -1653,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, @@ -2341,7 +2369,11 @@ This compaction should PRIORITISE preserving all information related to the focu 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() @@ -2352,6 +2384,9 @@ This compaction should PRIORITISE preserving all information related to the focu 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) From 7e46533d9f3ae879b87e6561d61394d89ba9c231 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:32:52 -0700 Subject: [PATCH 14/15] test: compressed-summary metadata flag set in-process, stripped on wire --- .../agent/test_compressed_summary_metadata.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/agent/test_compressed_summary_metadata.py diff --git a/tests/agent/test_compressed_summary_metadata.py b/tests/agent/test_compressed_summary_metadata.py new file mode 100644 index 000000000000..fba47767596a --- /dev/null +++ b/tests/agent/test_compressed_summary_metadata.py @@ -0,0 +1,93 @@ +"""Regression tests for the compressed-summary metadata flag (#38389). + +The compressor marks summary messages with ``COMPRESSED_SUMMARY_METADATA_KEY`` +so frontends (CLI, Desktop, gateway, TUI) can distinguish them from real +assistant/user messages without content-prefix heuristics. + +Two invariants: +1. The flag is present on exactly the summary-bearing message after compress() + (standalone insertion AND merge-into-tail). +2. The key is underscore-prefixed so the chat-completions wire sanitizer + strips it — strict gateways (Fireworks, Mistral, Moonshot/Kimi, + opencode-go) reject unknown message keys with "Extra inputs are not + permitted", poisoning the session. +""" +from unittest.mock import MagicMock, patch + +import pytest + +from agent.context_compressor import ( + COMPRESSED_SUMMARY_METADATA_KEY, + ContextCompressor, +) + + +def _make_compressor(): + with patch( + "agent.context_compressor.get_model_context_length", return_value=8000 + ): + return ContextCompressor( + model="test-model", quiet_mode=True, config_context_length=8000 + ) + + +def _make_messages(n_turns=30): + msgs = [{"role": "system", "content": "sys"}] + for i in range(n_turns): + msgs.append({"role": "user", "content": f"question {i} " + "x" * 400}) + msgs.append({"role": "assistant", "content": f"answer {i} " + "y" * 400}) + return msgs + + +def _compress(cc, msgs): + resp = MagicMock() + resp.choices[0].message.content = "## Active Task\nstuff" + with patch("agent.context_compressor.call_llm", return_value=resp): + return cc.compress(msgs, current_tokens=100_000, force=True) + + +class TestMetadataFlagSet: + def test_exactly_one_flagged_message_after_compress(self): + cc = _make_compressor() + out = _compress(cc, _make_messages()) + flagged = [ + m for m in out + if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY) + ] + assert len(flagged) == 1 + # The flagged message is the one carrying the compaction handoff. + assert "[CONTEXT COMPACTION" in flagged[0]["content"] + + def test_helper_detects_flag(self): + assert ContextCompressor._has_compressed_summary_metadata( + {COMPRESSED_SUMMARY_METADATA_KEY: True} + ) + assert not ContextCompressor._has_compressed_summary_metadata( + {"role": "assistant", "content": "hi"} + ) + assert not ContextCompressor._has_compressed_summary_metadata("not a dict") + assert not ContextCompressor._has_compressed_summary_metadata(None) + + +class TestMetadataFlagNeverReachesWire: + def test_key_is_underscore_prefixed(self): + """The wire sanitizers strip every top-level message key starting + with '_'. A bare key would reach strict gateways (Fireworks etc.) + and 400 with 'Extra inputs are not permitted'.""" + assert COMPRESSED_SUMMARY_METADATA_KEY.startswith("_") + + def test_chat_completions_transport_strips_flag(self): + from agent.transports.chat_completions import ChatCompletionsTransport + + cc = _make_compressor() + out = _compress(cc, _make_messages()) + wire = ChatCompletionsTransport().convert_messages(out, model="some-model") + assert not any( + isinstance(m, dict) and COMPRESSED_SUMMARY_METADATA_KEY in m + for m in wire + ) + # Sanitization must not destroy the in-process flag on the originals. + assert any( + isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY) + for m in out + ) From 9688c1a94f7df94043c1fe457e7cdc4b307d9969 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:55:40 -0700 Subject: [PATCH 15/15] chore: add Kimi K2.7 code catalog slug (#45283) --- hermes_cli/models.py | 2 ++ website/static/api/model-catalog.json | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index aa6996a4877d..39decf135faf 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -57,6 +57,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("qwen/qwen3.6-35b-a3b", ""), # MoonshotAI ("moonshotai/kimi-k2.6", "recommended"), + ("moonshotai/kimi-k2.7-code", ""), # MiniMax ("minimax/minimax-m3", ""), # Z-AI @@ -178,6 +179,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "qwen/qwen3.6-35b-a3b", # MoonshotAI "moonshotai/kimi-k2.6", + "moonshotai/kimi-k2.7-code", # MiniMax "minimax/minimax-m3", # Z-AI diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 11518d7b414b..b028ecd92457 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-06-09T17:20:16Z", + "updated_at": "2026-06-12T23:38:08Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -84,6 +84,10 @@ "id": "moonshotai/kimi-k2.6", "description": "recommended" }, + { + "id": "moonshotai/kimi-k2.7-code", + "description": "" + }, { "id": "minimax/minimax-m3", "description": "" @@ -199,6 +203,9 @@ { "id": "moonshotai/kimi-k2.6" }, + { + "id": "moonshotai/kimi-k2.7-code" + }, { "id": "minimax/minimax-m3" },