mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(google-chat): render clarify prompts as cards
This commit is contained in:
parent
6803519aa5
commit
09505a393e
2 changed files with 327 additions and 0 deletions
|
|
@ -186,6 +186,17 @@ _RETRY_BASE_DELAY = 1.0
|
|||
_RETRY_MAX_DELAY = 8.0
|
||||
_RETRY_JITTER = 0.3
|
||||
_RETRYABLE_HTTP_STATUSES = frozenset({429, 500, 502, 503, 504})
|
||||
_CARD_WIDGET_TYPES = frozenset({
|
||||
"text",
|
||||
"text_paragraph",
|
||||
"decorated_text",
|
||||
"buttons",
|
||||
"button_list",
|
||||
"selection",
|
||||
"selection_input",
|
||||
"image",
|
||||
"divider",
|
||||
})
|
||||
|
||||
|
||||
def _is_retryable_error(exc: BaseException) -> bool:
|
||||
|
|
@ -316,6 +327,136 @@ def _mime_for_message_type(mime: str) -> MessageType:
|
|||
return MessageType.DOCUMENT
|
||||
|
||||
|
||||
def _required_str(mapping: Dict[str, Any], key: str, context: str) -> str:
|
||||
value = mapping.get(key)
|
||||
if value is None:
|
||||
raise ValueError(f"{context}.{key} is required")
|
||||
value = str(value).strip()
|
||||
if not value:
|
||||
raise ValueError(f"{context}.{key} is required")
|
||||
return value
|
||||
|
||||
|
||||
def _button_to_chat(button: Dict[str, Any]) -> Dict[str, Any]:
|
||||
text = _required_str(button, "text", "button")
|
||||
action = _required_str(button, "action", "button")
|
||||
raw_params = button.get("parameters") or {}
|
||||
if not isinstance(raw_params, dict):
|
||||
raise ValueError("button.parameters must be an object")
|
||||
parameters = [
|
||||
{"key": str(key), "value": str(value)}
|
||||
for key, value in sorted(raw_params.items())
|
||||
]
|
||||
return {
|
||||
"text": text,
|
||||
"onClick": {"action": {"function": action, "parameters": parameters}},
|
||||
}
|
||||
|
||||
|
||||
def _widget_to_chat(widget: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(widget, dict):
|
||||
raise ValueError("card widgets must be objects")
|
||||
widget_type = str(widget.get("type") or "").strip()
|
||||
if widget_type not in _CARD_WIDGET_TYPES:
|
||||
raise ValueError(f"unsupported widget type: {widget_type or '<missing>'}")
|
||||
|
||||
if widget_type in {"text", "text_paragraph"}:
|
||||
return {
|
||||
"textParagraph": {
|
||||
"text": GoogleChatAdapter.format_message(
|
||||
_required_str(widget, "text", "widget")
|
||||
)
|
||||
}
|
||||
}
|
||||
if widget_type == "decorated_text":
|
||||
decorated: Dict[str, Any] = {
|
||||
"text": GoogleChatAdapter.format_message(
|
||||
_required_str(widget, "text", "widget")
|
||||
),
|
||||
"wrapText": bool(widget.get("wrap_text", True)),
|
||||
}
|
||||
if widget.get("top_label"):
|
||||
decorated["topLabel"] = str(widget["top_label"])
|
||||
if widget.get("bottom_label"):
|
||||
decorated["bottomLabel"] = str(widget["bottom_label"])
|
||||
return {"decoratedText": decorated}
|
||||
if widget_type == "divider":
|
||||
return {"divider": {}}
|
||||
if widget_type == "image":
|
||||
image = {"imageUrl": _required_str(widget, "image_url", "widget")}
|
||||
if widget.get("alt_text"):
|
||||
image["altText"] = str(widget["alt_text"])
|
||||
return {"image": image}
|
||||
if widget_type in {"buttons", "button_list"}:
|
||||
raw_buttons = widget.get("buttons") or []
|
||||
if not isinstance(raw_buttons, list) or not raw_buttons:
|
||||
raise ValueError("button widgets require at least one button")
|
||||
return {"buttonList": {"buttons": [_button_to_chat(btn) for btn in raw_buttons]}}
|
||||
if widget_type in {"selection", "selection_input"}:
|
||||
name = _required_str(widget, "name", "widget")
|
||||
raw_items = widget.get("items") or []
|
||||
if not isinstance(raw_items, list) or not raw_items:
|
||||
raise ValueError("selection widgets require at least one item")
|
||||
items: List[Dict[str, Any]] = []
|
||||
for item in raw_items:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("selection items must be objects")
|
||||
items.append({
|
||||
"text": _required_str(item, "text", "selection item"),
|
||||
"value": _required_str(item, "value", "selection item"),
|
||||
"selected": bool(item.get("selected", False)),
|
||||
})
|
||||
return {
|
||||
"selectionInput": {
|
||||
"name": name,
|
||||
"label": str(widget.get("label") or name),
|
||||
"type": str(widget.get("selection_type") or "CHECK_BOX"),
|
||||
"items": items,
|
||||
}
|
||||
}
|
||||
raise ValueError(f"unsupported widget type: {widget_type}")
|
||||
|
||||
|
||||
def card_spec_to_cards_v2(card_spec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(card_spec, dict):
|
||||
raise ValueError("card must be an object")
|
||||
|
||||
raw_sections = card_spec.get("sections") or []
|
||||
if not isinstance(raw_sections, list) or not raw_sections:
|
||||
raise ValueError("card.sections must contain at least one section")
|
||||
|
||||
sections: List[Dict[str, Any]] = []
|
||||
for section in raw_sections:
|
||||
if not isinstance(section, dict):
|
||||
raise ValueError("card sections must be objects")
|
||||
widgets = section.get("widgets") or []
|
||||
if not isinstance(widgets, list) or not widgets:
|
||||
raise ValueError("card section widgets must contain at least one widget")
|
||||
rendered: Dict[str, Any] = {"widgets": [_widget_to_chat(w) for w in widgets]}
|
||||
if section.get("header"):
|
||||
rendered["header"] = str(section["header"])
|
||||
sections.append(rendered)
|
||||
|
||||
card: Dict[str, Any] = {"sections": sections}
|
||||
header = card_spec.get("header")
|
||||
if header:
|
||||
if not isinstance(header, dict):
|
||||
raise ValueError("card.header must be an object")
|
||||
rendered_header: Dict[str, Any] = {
|
||||
"title": _required_str(header, "title", "card.header")
|
||||
}
|
||||
if header.get("subtitle"):
|
||||
rendered_header["subtitle"] = str(header["subtitle"])
|
||||
if header.get("image_url"):
|
||||
rendered_header["imageUrl"] = str(header["image_url"])
|
||||
rendered_header["imageType"] = str(header.get("image_type") or "SQUARE")
|
||||
if header.get("image_alt_text"):
|
||||
rendered_header["imageAltText"] = str(header["image_alt_text"])
|
||||
card["header"] = rendered_header
|
||||
|
||||
return {"cardId": str(card_spec.get("card_id") or "hermes-card"), "card": card}
|
||||
|
||||
|
||||
class _ThreadCountStore:
|
||||
"""Per-(chat_id, thread_name) inbound message counter, persisted to disk.
|
||||
|
||||
|
|
@ -502,6 +643,7 @@ class GoogleChatAdapter(BasePlatformAdapter):
|
|||
self._bot_user_id: Optional[str] = None # users/{id}
|
||||
self._dedup = MessageDeduplicator()
|
||||
self._typing_messages: Dict[str, str] = {}
|
||||
self._clarify_state: Dict[str, str] = {}
|
||||
self._shutting_down = False
|
||||
self._rate_limit_hits: Dict[str, int] = {}
|
||||
# Last-seen inbound thread name per chat_id (space). Google Chat
|
||||
|
|
@ -1870,6 +2012,108 @@ class GoogleChatAdapter(BasePlatformAdapter):
|
|||
finally:
|
||||
self.resume_typing_for_chat(chat_id)
|
||||
|
||||
async def send_card(
|
||||
self,
|
||||
chat_id: str,
|
||||
card: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
body: Dict[str, Any] = {"cardsV2": [card]}
|
||||
thread_id = self._resolve_thread_id(None, metadata, chat_id=chat_id)
|
||||
if thread_id:
|
||||
body["thread"] = {"name": thread_id}
|
||||
try:
|
||||
result = await self._create_message(chat_id, body)
|
||||
result.raw_response = result.raw_response or {"cardsV2": body["cardsV2"]}
|
||||
return result
|
||||
except HttpError as exc:
|
||||
status = getattr(getattr(exc, "resp", None), "status", None)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=_redact_sensitive(str(exc)),
|
||||
retryable=status in _RETRYABLE_HTTP_STATUSES,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("[GoogleChat] send_card failed", exc_info=True)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=_redact_sensitive(str(exc)),
|
||||
retryable=_is_retryable_error(exc),
|
||||
)
|
||||
|
||||
async def send_clarify(
|
||||
self,
|
||||
chat_id: str,
|
||||
question: str,
|
||||
choices: Optional[list],
|
||||
clarify_id: str,
|
||||
session_key: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
if not choices:
|
||||
return await super().send_clarify(
|
||||
chat_id, question, choices, clarify_id, session_key, metadata
|
||||
)
|
||||
|
||||
buttons: List[Dict[str, Any]] = []
|
||||
for choice in choices:
|
||||
choice_text = str(choice).strip()
|
||||
if not choice_text:
|
||||
continue
|
||||
label = choice_text if len(choice_text) <= 80 else choice_text[:77] + "..."
|
||||
buttons.append(
|
||||
{
|
||||
"text": label,
|
||||
"action": "hermes_clarify",
|
||||
"parameters": {
|
||||
"clarify_id": clarify_id,
|
||||
"choice": choice_text,
|
||||
},
|
||||
}
|
||||
)
|
||||
buttons.append(
|
||||
{
|
||||
"text": "Other / type answer",
|
||||
"action": "hermes_clarify",
|
||||
"parameters": {
|
||||
"clarify_id": clarify_id,
|
||||
"choice": "__other__",
|
||||
},
|
||||
}
|
||||
)
|
||||
if not buttons:
|
||||
return await super().send_clarify(
|
||||
chat_id, question, choices, clarify_id, session_key, metadata
|
||||
)
|
||||
|
||||
card = card_spec_to_cards_v2(
|
||||
{
|
||||
"card_id": f"clarify-{clarify_id}",
|
||||
"header": {"title": "Question"},
|
||||
"sections": [
|
||||
{
|
||||
"widgets": [
|
||||
{"type": "text", "text": f"❓ {question}"},
|
||||
{"type": "buttons", "buttons": buttons},
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
result = await self.send_card(chat_id, card, metadata=metadata)
|
||||
if result.success:
|
||||
self._clarify_state[clarify_id] = session_key
|
||||
try:
|
||||
from tools.clarify_gateway import mark_awaiting_text
|
||||
|
||||
mark_awaiting_text(clarify_id)
|
||||
except Exception as exc:
|
||||
logger.warning("[GoogleChat] mark_awaiting_text failed: %s", exc)
|
||||
return result
|
||||
return await super().send_clarify(
|
||||
chat_id, question, choices, clarify_id, session_key, metadata
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ from plugins.platforms.google_chat.adapter import ( # noqa: E402
|
|||
_is_google_owned_host,
|
||||
_mime_for_message_type,
|
||||
_redact_sensitive,
|
||||
card_spec_to_cards_v2,
|
||||
check_google_chat_requirements,
|
||||
)
|
||||
|
||||
|
|
@ -1004,6 +1005,88 @@ class TestSend:
|
|||
await adapter.send("spaces/S", "hola")
|
||||
assert adapter._rate_limit_hits.get("spaces/S") == 1
|
||||
|
||||
def test_card_spec_to_cards_v2_builds_button_card(self):
|
||||
card = card_spec_to_cards_v2(
|
||||
{
|
||||
"card_id": "approval",
|
||||
"header": {"title": "Approve request"},
|
||||
"sections": [
|
||||
{
|
||||
"widgets": [
|
||||
{"type": "text", "text": "Pick one"},
|
||||
{
|
||||
"type": "buttons",
|
||||
"buttons": [
|
||||
{
|
||||
"text": "Yes",
|
||||
"action": "approve",
|
||||
"parameters": {"choice": "yes"},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert card["cardId"] == "approval"
|
||||
assert card["card"]["header"]["title"] == "Approve request"
|
||||
button = card["card"]["sections"][0]["widgets"][1]["buttonList"]["buttons"][0]
|
||||
assert button["text"] == "Yes"
|
||||
assert button["onClick"]["action"]["function"] == "approve"
|
||||
assert {"key": "choice", "value": "yes"} in button["onClick"]["action"]["parameters"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_card_posts_cards_v2_with_thread(self, adapter):
|
||||
adapter._create_message = AsyncMock(
|
||||
return_value=type(
|
||||
"R",
|
||||
(),
|
||||
{"success": True, "message_id": "m/1", "error": None, "raw_response": None},
|
||||
)()
|
||||
)
|
||||
|
||||
result = await adapter.send_card(
|
||||
"spaces/S",
|
||||
{"cardId": "c1", "card": {"sections": [{"widgets": []}]}},
|
||||
metadata={"thread_id": "spaces/S/threads/T"},
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
body = adapter._create_message.await_args.args[1]
|
||||
assert body["cardsV2"][0]["cardId"] == "c1"
|
||||
assert body["thread"] == {"name": "spaces/S/threads/T"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_clarify_posts_choice_card(self, adapter):
|
||||
adapter._create_message = AsyncMock(
|
||||
return_value=type(
|
||||
"R",
|
||||
(),
|
||||
{"success": True, "message_id": "m/1", "error": None, "raw_response": None},
|
||||
)()
|
||||
)
|
||||
|
||||
result = await adapter.send_clarify(
|
||||
"spaces/S",
|
||||
"Pick a demo",
|
||||
["Simple", "Capability test"],
|
||||
"clarify123",
|
||||
"session-key",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
body = adapter._create_message.await_args.args[1]
|
||||
card = body["cardsV2"][0]
|
||||
assert card["cardId"] == "clarify-clarify123"
|
||||
buttons = card["card"]["sections"][0]["widgets"][1]["buttonList"]["buttons"]
|
||||
assert buttons[0]["text"] == "Simple"
|
||||
assert buttons[0]["onClick"]["action"]["function"] == "hermes_clarify"
|
||||
assert {"key": "choice", "value": "Simple"} in buttons[0]["onClick"]["action"]["parameters"]
|
||||
assert buttons[-1]["text"] == "Other / type answer"
|
||||
assert adapter._clarify_state["clarify123"] == "session-key"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# send_typing / stop_typing
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue