fix(hooks): /retry and /undo after a block use the typed text, not the placeholder

A vetoed user row stores the withheld placeholder as its content, so /retry
(CLI, TUI, gateway, ACP, Desktop) re-sent the placeholder as a new prompt and
/undo pre-filled it into the composer. A rewind's live view and its receipt now
swap the placeholder back for display_metadata.withheld_prompt: /retry
re-submits what the user typed, so the hook decides on it again, and /undo
restores it. Stored rows, compaction and search are unchanged.
This commit is contained in:
Teknium committed 2026-10-31 22:59:20 -07:00
1 parent c0bcfe2bb2
commit 4061818385
6 files changed
+56 -5

No files matched your search

+3 -3
View File
@@ -35,7 +35,7 @@ from agent.context_compressor_prellm import PreLlmSkipMixin
from agent.context_compressor_window import CompactionWindowMixin
from agent.context_compressor_prune import ProactivePruneMixin
from agent.context_compressor_summary import SummaryDispatchMixin
from agent.context_compressor_retry import retryable_user_text
from agent.context_compressor_retry import retryable_user_text, typed_prompt_content
from agent.context_compressor_tail import (
demote_stale_tail_tools, lean_tail_budget, is_demoted_stub, skill_view_names_by_call_id, tail_cut_pricer,
elide_keeping_skill_safety_rule, with_skill_safety_rule, without_skill_safety_rule, tail_cut_progress_floor,
@@ -5691,8 +5691,8 @@ def history_before_user_originated_turn(
handoff, live_view = split_user_originated_turn(messages[index])
if live_view is None:
raise ValueError("selected row is not a user-originated turn")
prefix = [message.copy() for message in messages[:index]] + ([handoff] if handoff is not None else [])
return prefix, live_view
live_view["content"] = typed_prompt_content(live_view.get("content"), messages[index]) # never the placeholder
return [message.copy() for message in messages[:index]] + ([handoff] if handoff is not None else []), live_view
def _handoff_carries_live_user_content(message: Any) -> bool:
+2
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
from agent.message_metadata import typed_prompt_content # re-exported with the retry text helper
def retryable_user_text(content: Any) -> str:
"""Lossless retry text, or raise before destructive mutation (media/unknown parts fail closed: no replay protocol)."""
+16
View File
@@ -78,6 +78,7 @@ def stamp_data_use(message: MutableMapping[str, Any], requires: list[str]) -> No
# replay, compaction, session_search, memory providers, background review) only ever sees that; transcript
# views render this key instead (agent/turn_context.py::_withhold_blocked_prompt).
WITHHELD_PROMPT = "withheld_prompt"
PRE_LLM_BLOCK_WITHHELD_PROMPT = "[Message withheld: a pre_llm_call policy hook blocked it.]"
def withheld_prompt_text(message: Any) -> Optional[str]:
@@ -87,6 +88,21 @@ def withheld_prompt_text(message: Any) -> Optional[str]:
return text if isinstance(text, str) else None
def typed_prompt_content(content: Any, message: Any) -> Any:
"""*content* (a rewind's live view of *message*) with the withheld placeholder swapped back for what
the user typed: ``/retry`` re-submits the real ask so the hook decides again, and ``/undo`` restores it
to the composer. Never the placeholder as a new prompt."""
typed = withheld_prompt_text(message)
if typed is None:
return content
if isinstance(content, str):
return content.replace(PRE_LLM_BLOCK_WITHHELD_PROMPT, typed)
if isinstance(content, list):
return [{**part, "text": part["text"].replace(PRE_LLM_BLOCK_WITHHELD_PROMPT, typed)}
if isinstance(part, dict) and isinstance(part.get("text"), str) else part for part in content]
return content
def without_withheld_prompts(messages: Any) -> Any:
"""*messages* with the vetoed text dropped from each row's ``display_metadata`` (copies only where
one is present), for hand-offs that leave the host with whole row dicts (memory providers)."""
+3 -2
View File
@@ -26,7 +26,9 @@ from agent.lsp.deferred import forward_to_agent as forward_lsp_verdicts
from agent.plugin_live_notes import pop_plugin_notices
from agent.skills_index_delta import stage_skills_index_note
from agent.prompt_refresh import refresh_warm_prompt
from agent.message_metadata import PERSISTENCE_ONLY_MESSAGE_FIELDS, append_message, stamp_message_timestamp
from agent.message_metadata import (
PERSISTENCE_ONLY_MESSAGE_FIELDS, PRE_LLM_BLOCK_WITHHELD_PROMPT, append_message, stamp_message_timestamp,
)
from agent.model_metadata import (
estimate_messages_tokens_rough,
estimate_native_anthropic_request_tokens_rough,
@@ -804,7 +806,6 @@ def _ensure_session_row(agent: Any, pending_cli_message: Any) -> None:
# ``turn_exit_reason`` of a turn a ``pre_llm_call`` hook vetoed before any inference request, and
# the stored ``content`` of the vetoed user row (what the user typed is display-only metadata).
PRE_LLM_BLOCK_EXIT_REASON = "blocked_by_plugin_pre_llm_call"
PRE_LLM_BLOCK_WITHHELD_PROMPT = "[Message withheld: a pre_llm_call policy hook blocked it.]"
def _withhold_blocked_prompt(agent: Any, turn_user_msg: dict[str, Any], typed: Any) -> None:
+2
View File
@@ -149,6 +149,8 @@ def _rewind(db, conn, session_id, payload):
target = db._row_to_message_dict(target, warn_context='rewind receipt', summary_flag=True)
if live is not None:
target['content'] = live.get('content')
from agent.message_metadata import typed_prompt_content # /retry, /undo: the typed ask, not the placeholder
target['content'] = typed_prompt_content(target.get('content'), target)
conn.execute('UPDATE sessions SET runtime_generation=runtime_generation+1 WHERE id=?', (session_id,))
return {session_id, physical}, {'rewound_count': len(ids), 'target_message': target,
'new_head_id': head, 'replacement_message_id': replacement}
+30
View File
@@ -133,6 +133,36 @@ def test_compaction_of_a_blocked_turn_never_sends_the_prompt(provider_url, tmp_p
db.close()
def test_retry_and_undo_after_a_block_resubmit_the_typed_text(provider_url, tmp_path, monkeypatch):
"""/retry (CLI, TUI, gateway, ACP, Desktop) and /undo read a rewind's live view or receipt: both carry
what the user typed, so the hook re-decides on it (0 model calls, blocked again), never the placeholder."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
import hermes_state_runtime as rt
typed = f"look up {SECRET}"
for name in ("receipt", "rewind"):
db = SessionDB(db_path=tmp_path / f"{name}.db")
try:
_blocked_session(provider_url, db)
if name == "receipt":
epoch = rt.begin_runtime_epoch(db, instance_id="owner")
receipt = rt.mutate_runtime_session(
db, epoch=epoch, principal_id="human", session_id="s-block", request_id="r", expected_revision=0,
expected_generation=0, operation="rewind", payload={"target_message_id": db.get_messages("s-block")[0]["id"]})
assert receipt["target_message"]["content"] == typed
continue
history = db.get_messages_as_conversation("s-block")
outcome = db.rewind_user_turn("s-block", -1, warm_history=history, require_retryable=True)
assert outcome.live_text == typed and outcome.prefix == []
dlp = lambda hook, **kw: ([{"action": "block", "message": BLOCK_MSG}] if hook == "pre_llm_call"
and SECRET in kw["user_message"] else [])
with patch("hermes_cli.plugins.invoke_hook", side_effect=dlp):
again = _agent(provider_url, db).run_conversation(outcome.live_text, conversation_history=[], task_id="t")
assert again["turn_exit_reason"] == PRE_LLM_BLOCK_EXIT_REASON and _Provider.chat_requests == []
finally:
db.close()
@pytest.mark.parametrize(("stdout", "expected"), [
('{"action": "block", "message": "no"}', {"action": "block", "message": "no"}),
('{"decision": "block", "reason": "no"}', {"action": "block", "message": "no"}),