mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix(clarify): reject arbitrary prose for native interactive multi-choice clarifies
In Slack threads, ordinary follow-up messages sent while a native
multi-choice clarify was pending were consumed as clarify answers —
_coerce_text_response accepted arbitrary text for any pending entry, so
the gateway text-intercept swallowed unrelated thread messages and the
user's messages appeared to be ignored.
Tighten resolve_text_response_for_session for native interactive
multi-choice prompts (buttons rendered, awaiting_text=False):
- numeric selections ("2") still resolve to the canonical choice
- exact choice-label matches (case-insensitive) still resolve
- arbitrary prose is now REJECTED (returns False) so the message
continues as a normal turn instead of vanishing into the clarify
Behavior is preserved everywhere free text is legitimately the answer:
open-ended clarifies, explicit 'Other' text-capture mode, and the base
adapter's numbered-text fallback (which flips awaiting_text at send
time).
Salvaged from PR #62042 by @liuhao1024.
Fixes #62034
This commit is contained in:
parent
95aad9229b
commit
07cbb500b6
2 changed files with 98 additions and 16 deletions
|
|
@ -82,14 +82,58 @@ class TestClarifyPrimitive:
|
|||
assert cm.wait_for_response("id3c", timeout=0.1) == "Y"
|
||||
|
||||
def test_resolve_text_response_accepts_custom_other_text(self):
|
||||
"""Arbitrary typed text should resolve as a custom Other answer."""
|
||||
"""Arbitrary typed text should resolve as a custom Other answer when awaiting_text is True."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
cm.register("id3d", "sk3d", "Pick", ["X", "Y"])
|
||||
# Flip to text-capture mode (user picked "Other")
|
||||
cm.mark_awaiting_text("id3d")
|
||||
custom = "None of those are valid options"
|
||||
assert cm.resolve_text_response_for_session("sk3d", custom) is True
|
||||
assert cm.wait_for_response("id3d", timeout=0.1) == custom
|
||||
|
||||
def test_resolve_text_rejects_arbitrary_prose_for_native_multi_choice(self):
|
||||
"""Native interactive multi-choice clarifies reject arbitrary prose unless awaiting_text is True."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
# Native multi-choice (buttons, not awaiting text)
|
||||
cm.register("id-strict", "sk-strict", "Pick one", ["A", "B", "C"])
|
||||
|
||||
# Arbitrary prose should be rejected
|
||||
assert cm.resolve_text_response_for_session("sk-strict", "just checking the visual UI") is False
|
||||
assert cm.resolve_text_response_for_session("sk-strict", "present 3 buttons") is False
|
||||
|
||||
# Numeric choices should still work
|
||||
assert cm.resolve_text_response_for_session("sk-strict", "2") is True
|
||||
assert cm.wait_for_response("id-strict", timeout=0.1) == "B"
|
||||
|
||||
# Exact label match should still work
|
||||
cm.register("id-strict2", "sk-strict2", "Pick", ["Option Alpha", "Option Beta"])
|
||||
assert cm.resolve_text_response_for_session("sk-strict2", "Option Alpha") is True
|
||||
assert cm.wait_for_response("id-strict2", timeout=0.1) == "Option Alpha"
|
||||
|
||||
def test_text_fallback_mode_allows_any_text(self):
|
||||
"""Text fallback mode (after base send_clarify calls mark_awaiting_text) accepts any text."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
entry = cm.register("id-tf", "sk-tf", "Pick one", ["A", "B", "C"])
|
||||
assert entry.awaiting_text is False
|
||||
|
||||
# Simulate base send_clarify calling mark_awaiting_text
|
||||
cm.mark_awaiting_text("id-tf")
|
||||
assert entry.awaiting_text is True
|
||||
|
||||
# Now arbitrary text is accepted
|
||||
custom = "I choose a custom answer"
|
||||
assert cm.resolve_text_response_for_session("sk-tf", custom) is True
|
||||
assert cm.wait_for_response("id-tf", timeout=0.1) == custom
|
||||
|
||||
# Numeric choices also work
|
||||
cm.register("id-tf2", "sk-tf2", "Pick", ["X", "Y"])
|
||||
cm.mark_awaiting_text("id-tf2")
|
||||
assert cm.resolve_text_response_for_session("sk-tf2", "1") is True
|
||||
assert cm.wait_for_response("id-tf2", timeout=0.1) == "X"
|
||||
|
||||
def test_other_button_flips_to_text_mode(self):
|
||||
"""mark_awaiting_text makes get_pending_for_session find the entry."""
|
||||
from tools import clarify_gateway as cm
|
||||
|
|
|
|||
|
|
@ -187,30 +187,68 @@ def get_pending_for_session(
|
|||
return None
|
||||
|
||||
|
||||
def _coerce_text_response(entry: _ClarifyEntry, response: str) -> str:
|
||||
"""Map typed choice replies to canonical choice text, otherwise keep custom text."""
|
||||
def _coerce_text_response(entry: _ClarifyEntry, response: str) -> Optional[str]:
|
||||
"""Map typed choice replies to canonical choice text, otherwise keep or reject custom text.
|
||||
|
||||
For native interactive multi-choice clarifies (button UI, awaiting_text=False):
|
||||
- Accept numeric selections ("2" → choice[1])
|
||||
- Accept exact choice label matches (case-insensitive)
|
||||
- Reject arbitrary prose (return None) so the message continues as a normal turn
|
||||
|
||||
For text fallback or awaiting_text mode:
|
||||
- Accept any text (numeric/label/custom) after passing through coercion
|
||||
|
||||
For open-ended clarifies (no choices):
|
||||
- Accept any text
|
||||
|
||||
Returns None when the response should be rejected (arbitrary prose for native multi-choice).
|
||||
"""
|
||||
text = str(response).strip()
|
||||
if entry.choices:
|
||||
try:
|
||||
idx = int(text) - 1
|
||||
except ValueError:
|
||||
idx = -1
|
||||
if 0 <= idx < len(entry.choices):
|
||||
return entry.choices[idx]
|
||||
for choice in entry.choices:
|
||||
if text.casefold() == str(choice).strip().casefold():
|
||||
return str(choice).strip()
|
||||
return text
|
||||
|
||||
if not entry.choices:
|
||||
# Open-ended: accept any text
|
||||
return text
|
||||
|
||||
# Try numeric selection first (always valid for multi-choice)
|
||||
try:
|
||||
idx = int(text) - 1
|
||||
except ValueError:
|
||||
idx = -1
|
||||
|
||||
if 0 <= idx < len(entry.choices):
|
||||
return entry.choices[idx]
|
||||
|
||||
# Try exact choice label match (always valid for multi-choice)
|
||||
for choice in entry.choices:
|
||||
if text.casefold() == str(choice).strip().casefold():
|
||||
return str(choice).strip()
|
||||
|
||||
# For text fallback or awaiting_text mode, accept custom text
|
||||
# For native interactive multi-choice mode, reject arbitrary prose
|
||||
if entry.awaiting_text:
|
||||
return text
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_text_response_for_session(session_key: str, response: str) -> bool:
|
||||
"""Resolve the oldest pending clarify in ``session_key`` from typed text."""
|
||||
"""Resolve the oldest pending clarify in ``session_key`` from typed text.
|
||||
|
||||
Returns False if no pending clarify exists or if the response was rejected
|
||||
(arbitrary prose for native interactive multi-choice clarifies).
|
||||
"""
|
||||
entry = get_pending_for_session(session_key, include_choice_prompts=True)
|
||||
if entry is None:
|
||||
return False
|
||||
|
||||
coerced = _coerce_text_response(entry, response)
|
||||
if coerced is None:
|
||||
# Response rejected: message should continue as a normal turn
|
||||
return False
|
||||
|
||||
return resolve_gateway_clarify(
|
||||
entry.clarify_id,
|
||||
_coerce_text_response(entry, response),
|
||||
coerced,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue