mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
* fix(desktop): preserve interim assistant text wiped at message.complete
When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.
This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).
The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.
Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.
Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.
Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.
The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.
Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.
Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.
Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.
_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)
- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
* fix: prefix-match interim streamed content to avoid benign duplicate bubbles
_interim_content_was_streamed used exact equality (streamed == visible_content),
so a final response that was the streamed text plus a trailing delta — or a
partial stream before the verify nudge fired — failed the match and left
_response_was_previewed false. The turn then showed two bubbles (interim +
identical final) instead of settling the interim in place.
Relax to a prefix check (visible_content.startswith(streamed)) in both the
core match and the desktop's settle-in-place gate. The TUI already used
prefix matching via finalTail. The reverse direction (streamed longer than
final) is intentionally not matched — that could suppress a needed resend
in the gateway path where already_streamed=True calls on_segment_break().
* test(desktop): add partial-stream-then-nudge dedup edge case
Third edge case for the interim-sealing dedup: model streams part of its
answer via message.delta, verify nudge fires, interim seals the streamed
prefix, then the final response is the same text plus a trailing delta.
Asserts one bubble (not two) containing the full final text.
Acceptance protocol #2 — covers all three dedup edges:
1. interim == final (existing)
2. interim = strict prefix of final (existing)
3. partial-stream-then-nudge (this commit)
---------
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
126 lines
5 KiB
Python
126 lines
5 KiB
Python
"""Verification-loop synthetic scaffolding must never reach durable session state.
|
|
|
|
verify_on_stop / pre_verify inject a synthetic user nudge to keep the agent
|
|
going one more turn before it can claim completion. The assistant response is
|
|
real content that persists and is emitted to the UI as an interim message.
|
|
Only the nudge (the synthetic user message) is flagged, so only the nudge
|
|
gets stripped from the durable transcript. This test file verifies:
|
|
|
|
- The verification-loop flags remain registered in
|
|
``_EPHEMERAL_SCAFFOLDING_FLAGS`` (so nudges are stripped).
|
|
- The DB flush drops only the nudge, keeping the assistant candidate.
|
|
- The JSON log drops only the nudge, keeping the assistant candidate.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
def _fresh_run_agent(hermes_home):
|
|
for mod in list(sys.modules):
|
|
if mod == "run_agent" or mod.startswith("agent.") or mod.startswith("tools.") or mod.startswith("hermes_"):
|
|
del sys.modules[mod]
|
|
import run_agent # noqa: F401
|
|
return sys.modules["run_agent"]
|
|
|
|
|
|
def test_verification_flags_registered_as_ephemeral(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
ra = _fresh_run_agent(tmp_path)
|
|
|
|
assert "_verification_stop_synthetic" in ra._EPHEMERAL_SCAFFOLDING_FLAGS
|
|
assert "_pre_verify_synthetic" in ra._EPHEMERAL_SCAFFOLDING_FLAGS
|
|
|
|
# The nudge messages ARE scaffolding (they carry the synthetic flag).
|
|
assert ra._is_ephemeral_scaffolding(
|
|
{"role": "user", "content": "[System: run tests]", "_pre_verify_synthetic": True}
|
|
)
|
|
assert ra._is_ephemeral_scaffolding(
|
|
{"role": "user", "content": "[System: run tests]", "_verification_stop_synthetic": True}
|
|
)
|
|
# Real messages (including the assistant candidate) are not.
|
|
assert not ra._is_ephemeral_scaffolding({"role": "user", "content": "hi"})
|
|
assert not ra._is_ephemeral_scaffolding({"role": "assistant", "content": "premature done"})
|
|
|
|
|
|
def _make_agent(ra, session_id, tmp_path):
|
|
agent = ra.AIAgent(
|
|
session_id=session_id,
|
|
api_key="test-key",
|
|
base_url="http://127.0.0.1:8000/v1",
|
|
provider="openai-compat",
|
|
model="test-model",
|
|
quiet_mode=True,
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
agent._session_db = MagicMock()
|
|
agent._session_db_created = True
|
|
agent._session_json_enabled = True
|
|
agent.logs_dir = tmp_path / "logs"
|
|
agent.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
return agent
|
|
|
|
|
|
def test_db_flush_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch):
|
|
"""The assistant candidate is NOT flagged synthetic, so it persists.
|
|
Only the nudge (flagged synthetic) is dropped from the DB flush."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
ra = _fresh_run_agent(tmp_path)
|
|
agent = _make_agent(ra, "sess_db", tmp_path)
|
|
|
|
messages = [
|
|
{"role": "user", "content": "hi"},
|
|
# Assistant candidate — NOT flagged synthetic, persists.
|
|
{"role": "assistant", "content": "premature done"},
|
|
# Nudge — flagged synthetic, gets dropped.
|
|
{"role": "user", "content": "[System: run tests]", "_verification_stop_synthetic": True},
|
|
{"role": "assistant", "content": "verified and clean"},
|
|
]
|
|
|
|
agent._flush_messages_to_session_db(messages, conversation_history=[])
|
|
|
|
persisted = [
|
|
kwargs.get("content")
|
|
for _args, kwargs in agent._session_db.append_message.call_args_list
|
|
]
|
|
assert "hi" in persisted
|
|
assert "verified and clean" in persisted
|
|
# The assistant candidate persists — it is real content.
|
|
assert "premature done" in persisted
|
|
# Only the nudge is dropped.
|
|
assert "[System: run tests]" not in persisted
|
|
|
|
|
|
def test_json_log_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch):
|
|
"""The assistant candidate is NOT flagged synthetic, so it persists in the
|
|
JSON log. Only the nudge (flagged synthetic) is dropped."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
ra = _fresh_run_agent(tmp_path)
|
|
agent = _make_agent(ra, "sess_json", tmp_path)
|
|
|
|
messages = [
|
|
{"role": "user", "content": "hi"},
|
|
# Assistant candidate — NOT flagged synthetic, persists.
|
|
{"role": "assistant", "content": "premature done"},
|
|
# Nudge — flagged synthetic, gets dropped.
|
|
{"role": "user", "content": "[System: run tests]", "_pre_verify_synthetic": True},
|
|
{"role": "assistant", "content": "verified and clean"},
|
|
]
|
|
|
|
agent._save_session_log(messages)
|
|
|
|
log_file = agent.logs_dir / "session_sess_json.json"
|
|
assert log_file.exists()
|
|
data = json.loads(log_file.read_text(encoding="utf-8"))
|
|
contents = [m.get("content") for m in data["messages"]]
|
|
# The assistant candidate persists — it is real content.
|
|
assert "premature done" in contents
|
|
assert "verified and clean" in contents
|
|
assert "hi" in contents
|
|
# Only the nudge is dropped.
|
|
assert "[System: run tests]" not in contents
|
|
assert all(not m.get("_pre_verify_synthetic") for m in data["messages"])
|