mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
c0bcfe2bb2
commit
4061818385
6 files changed
+56
-5
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
Reference in new issue
Block a user