Merge upstream main into feat/hermes-relay-shared-metrics

This commit is contained in:
Alex Fournier 2026-07-27 17:09:18 -07:00
commit e23ef9f312
6 changed files with 87 additions and 39 deletions

View file

@ -958,12 +958,6 @@ def init_agent(
agent._stream_writer_tls = threading.local()
agent._stream_writer_dropped = 0
# Displayed reasoning text streamed during the current model response,
# captured only when a surface consumed it via a reasoning callback. Used
# by active-turn redirect to checkpoint what the user actually saw without
# ever persisting hidden provider reasoning.
agent._current_streamed_reasoning_text = ""
# Optional current-turn user-message override used when the API-facing
# user message intentionally differs from the persisted transcript
# (e.g. CLI voice mode adds a temporary prefix for the live call only).

View file

@ -121,22 +121,31 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text
Incomplete provider reasoning blocks are not valid replay items (Anthropic
signs them; Responses reasoning items require their following output).
Preserve only what Hermes actually displayed, demoted to ordinary text,
then add the correction as a real user message. This keeps role alternation
Preserve only the *visible* response text, demoted to ordinary text, then
add the correction as a real user message. This keeps role alternation
valid and leaves every previously cached message byte-for-byte unchanged.
INVARIANT raw chain-of-thought must never be serialized into replayable
message content. Streamed reasoning is display-only state: it may be shown
live, but it does not re-enter the transcript as assistant (or user) text.
An assistant turn whose content inlines its own chain-of-thought reads to
Anthropic's output classifier as reasoning-injection/prefill jailbreak,
and because the poisoned checkpoint is persisted and replayed on every
subsequent call, the session dies permanently with deterministic
"Provider returned an empty response" storms that no retry, nudge, or
empty-recovery branch can escape (July 2026: four sessions bricked this
way; every reasoning-free checkpoint that week was untouched same
mechanism as the ~/.hermes/prefill.json incident, 20/20 blocked with
assistant-exposed CoT vs 0/20 without). The interrupted reasoning was
incomplete by definition; the model regenerates it on the retried turn.
If a future path needs to preserve interrupted thinking, carry it in a
provider-gated reasoning *field*, never in content.
"""
reasoning = str(
getattr(agent, "_current_streamed_reasoning_text", "") or ""
).strip()
visible = agent._strip_think_blocks(
getattr(agent, "_current_streamed_assistant_text", "") or ""
).strip()
checkpoint_parts = ["[This response was interrupted by a user correction.]"]
if reasoning:
checkpoint_parts.extend(
["Reasoning shown before the interruption:", reasoning]
)
if visible:
checkpoint_parts.extend(
["Visible response before the interruption:", visible]
@ -159,7 +168,6 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text
messages.append({"role": "user", "content": text})
agent._current_streamed_assistant_text = ""
agent._current_streamed_reasoning_text = ""
agent._stream_needs_break = True

View file

@ -5,6 +5,11 @@ import { useI18n } from '@/i18n'
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
import { cn } from '@/lib/utils'
/** Default hover-open delay for `Tip`. Non-zero so a cursor sweeping across the
* chrome doesn't flash a trail of tips they only appear on a deliberate,
* settled hover. Call sites that need an instant tip pass `delayDuration={0}`. */
const TIP_DELAY_MS = 200
/** True inside `RootTooltipProvider`. `Tip` uses this to decide whether it
* needs to supply its own provider see the note on `Tip`. */
const HasTooltipProvider = React.createContext(false)
@ -79,7 +84,8 @@ function TooltipContent({
// Transparent, width-capped wrapper. The visible chip is the inner inline
// span so `box-decoration-break: clone` gives a marker-style background
// that hugs EACH wrapped line (bg only on the text, ragged right — no
// rectangular dead space). Instant, no transition (delayDuration=0).
// rectangular dead space). No fade transition — once the hover delay
// elapses the chip appears at once.
// pointer-events-none: the tip must never steal hover/clicks from the
// chrome underneath (titlebar tools, adjacent tabs, etc.).
className={cn('pointer-events-none z-(--z-over-modal) w-fit max-w-64 select-none', className)}
@ -127,7 +133,7 @@ interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Con
// tried and reverted. `asChild` puts `data-slot="tooltip-trigger"` on the
// child element itself, so arming REPLACES that node — which broke 18 tests
// encoding that contract, and risks focus/ref identity at every call site.
function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
function Tip({ label, children, delayDuration = TIP_DELAY_MS, ...props }: TipProps) {
// A component rendered in isolation (every unit test, and any surface
// mounted outside the app root) has no provider above it, and Radix throws
// "`Tooltip` must be used within `TooltipProvider`". Fall back to a local

View file

@ -5323,7 +5323,6 @@ class AIAgent:
pass
self._record_streamed_assistant_text(tail)
self._current_streamed_assistant_text = ""
self._current_streamed_reasoning_text = ""
def _record_streamed_assistant_text(self, text: str) -> None:
"""Accumulate visible assistant text emitted through stream callbacks."""
@ -5664,15 +5663,6 @@ class AIAgent:
cb(text)
except Exception:
pass
else:
# Only checkpoint reasoning that a surface actually displayed.
# show_reasoning=false leaves the callback unset, so hidden
# provider thinking never becomes visible transcript content.
if isinstance(text, str) and text:
self._current_streamed_reasoning_text = (
getattr(self, "_current_streamed_reasoning_text", "")
+ text
)
def _fire_tool_gen_started(self, tool_name: str) -> None:
"""Notify display layer that the model is generating tool call arguments.

View file

@ -5210,7 +5210,8 @@ class TestRunConversation:
}
def test_redirect_during_thinking_retries_same_turn_with_context(self, agent):
"""A corrective follow-up keeps displayed reasoning and does not end the turn."""
"""A corrective follow-up does not end the turn, and displayed reasoning
never re-enters the transcript (classifier-poisoning guard)."""
self._setup_agent(agent)
agent.reasoning_callback = lambda _text: None
final = _mock_response(content="Using Postgres instead.", finish_reason="stop")
@ -5252,7 +5253,11 @@ class TestRunConversation:
]
checkpoint = replay[-2]["content"]
assert "interrupted by a user correction" in checkpoint
assert "I should implement this with SQLite." in checkpoint
# Displayed chain-of-thought must NOT be replayed: an assistant turn
# inlining its own reasoning trips Anthropic's output classifier and
# bricks the session with deterministic empty responses (July 2026).
assert "I should implement this with SQLite." not in checkpoint
assert "Reasoning shown before the interruption" not in checkpoint
assert replay[-1]["content"] == "No, use Postgres instead."
assert agent._pending_redirect is None
assert any(
@ -5334,7 +5339,10 @@ class TestRunConversation:
assert results["result"]["completed"] is True
assert results["result"]["final_response"] == "Corrected answer."
checkpoint = results["result"]["messages"][-3]
assert "Following the original approach." in checkpoint["content"]
assert "interrupted by a user correction" in checkpoint["content"]
# Displayed reasoning is display-only — replaying it as assistant
# content trips Anthropic's output classifier (July 2026 brickings).
assert "Following the original approach." not in checkpoint["content"]
assert results["result"]["messages"][-2]["content"] == (
"Use the corrected approach."
)

View file

@ -35,7 +35,6 @@ def _bare_agent() -> AIAgent:
agent._active_children_lock = threading.Lock()
agent._tool_worker_threads = None
agent._tool_worker_threads_lock = None
agent._current_streamed_reasoning_text = ""
agent._current_streamed_assistant_text = ""
agent._stream_needs_break = False
agent._strip_think_blocks = lambda content: content
@ -125,14 +124,20 @@ class TestActiveTurnRedirect:
assert agent.redirect("too late") is False
assert agent._pending_redirect is None
def test_hidden_reasoning_is_not_checkpointed(self):
def test_reasoning_deltas_are_display_only(self):
"""Streamed reasoning must never accumulate into replayable transcript
state an assistant checkpoint that inlines chain-of-thought trips
Anthropic's output classifier and permanently bricks the session
(deterministic empty-response storms on every replay)."""
agent = _bare_agent()
agent.reasoning_callback = None
agent._current_streamed_reasoning_text = ""
seen = []
agent.reasoning_callback = seen.append
agent._fire_reasoning_delta("private provider thinking")
agent._fire_reasoning_delta("visible provider thinking")
assert agent._current_streamed_reasoning_text == ""
# Displayed to the surface, but never checkpointed anywhere.
assert seen == ["visible provider thinking"]
assert not getattr(agent, "_current_streamed_reasoning_text", "")
def test_response_completion_before_redirect_lock_rejects_correction(self):
agent = _bare_agent()
@ -227,7 +232,6 @@ class TestActiveTurnRedirectCheckpoint:
from agent.conversation_loop import _apply_active_turn_redirect
agent = _bare_agent()
agent._current_streamed_reasoning_text = "Shown reasoning."
agent._current_streamed_assistant_text = "Visible draft."
messages = [
{"role": "user", "content": "start"},
@ -240,10 +244,48 @@ class TestActiveTurnRedirectCheckpoint:
assert messages[-1]["role"] == "user"
assert messages[-1]["content"].endswith("Use Postgres instead.")
assert sum(1 for m in messages if m["role"] == "assistant") == 1
assert "Shown reasoning." in messages[-1]["content"]
assert "Visible draft." in messages[-1]["content"]
assert "Context from the interrupted assistant response" in messages[-1]["content"]
def test_checkpoint_never_replays_chain_of_thought(self):
"""Raw CoT serialized into checkpoint content reads to Anthropic's
output classifier as reasoning-injection; because the checkpoint is
persisted and replayed on every later call, one redirect during a
thinking phase permanently bricked sessions with deterministic
empty-response storms (July 2026). Reasoning must never appear in
replayable content in either the assistant-checkpoint or the
merged-user-correction shape."""
from agent.conversation_loop import _apply_active_turn_redirect
for tail_role in ("user", "assistant"):
agent = _bare_agent()
# Simulate a surface having displayed reasoning this turn.
agent._current_streamed_reasoning_text = "SECRET chain of thought."
agent._current_streamed_assistant_text = "Visible draft."
messages = [{"role": "user", "content": "start"}]
if tail_role == "assistant":
messages.append({"role": "assistant", "content": "committed"})
_apply_active_turn_redirect(agent, messages, "Change course.")
serialized = "".join(str(m.get("content", "")) for m in messages)
assert "SECRET chain of thought." not in serialized
assert "Reasoning shown before the interruption" not in serialized
assert "Visible draft." in serialized
def test_checkpoint_omits_reasoning_label_when_nothing_visible(self):
from agent.conversation_loop import _apply_active_turn_redirect
agent = _bare_agent()
agent._current_streamed_reasoning_text = "thinking only, no text yet"
messages = [{"role": "user", "content": "start"}]
_apply_active_turn_redirect(agent, messages, "New direction.")
checkpoint = messages[-2]["content"]
assert checkpoint == "[This response was interrupted by a user correction.]"
assert messages[-1]["content"] == "New direction."
class TestSteerInjection:
def test_appends_to_last_tool_result(self):