mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
feat(slack): opt-in Block Kit rendering for agent messages
Add platforms.slack.extra.rich_blocks (default off). When enabled, the final agent message is sent as Slack Block Kit blocks — section headers, dividers, and true nested lists via rich_text — instead of flat mrkdwn. - New plugins/platforms/slack/block_kit.py: pure markdown->blocks renderer (headers, dividers, nested ordered/bullet lists, blockquotes, fenced code; pipe-tables as aligned monospace since Block Kit has no robust table block). Enforces Slack's 50-block / 3000-char section limits and returns None to fall back to plain text on empty/oversized/unexpected input. Never raises. - adapter.send(): render blocks on the single-chunk primary message; a text= fallback is ALWAYS sent alongside (notifications/accessibility). - adapter.edit_message(): blocks only on finalize=True, so intermediate streaming edits stay plain mrkdwn (no per-flush block re-derivation). - Docs (EN + zh-Hans) + config example. Send-side only: no app reinstall. Tests: pure-renderer unit suite + adapter integration suite (blocks present when on, plain text when off, text fallback always set, finalize gating, multi-chunk fallback). Prove-failed against a stubbed renderer.
This commit is contained in:
parent
2e8748ed22
commit
b080b93ad8
6 changed files with 707 additions and 5 deletions
|
|
@ -54,6 +54,11 @@ from gateway.platforms.base import (
|
|||
cache_video_from_bytes,
|
||||
)
|
||||
|
||||
try: # sibling module; support both package and flat plugin-dir import
|
||||
from .block_kit import render_blocks
|
||||
except ImportError: # pragma: no cover - plugin loaded outside package context
|
||||
from block_kit import render_blocks # type: ignore
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -1372,12 +1377,21 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
# Controlled via platform config: gateway.slack.reply_broadcast
|
||||
broadcast = self.config.extra.get("reply_broadcast", False)
|
||||
|
||||
# Block Kit (opt-in): render the primary message as structured
|
||||
# blocks. Only applied to a single-chunk message — a >39k response
|
||||
# that had to be split is pathological for Block Kit's 50-block /
|
||||
# 3000-char limits, so those fall back to plain text. The ``text``
|
||||
# field is always kept as the notification/accessibility fallback.
|
||||
blocks = self._maybe_blocks(content) if len(chunks) == 1 else None
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
kwargs = {
|
||||
"channel": chat_id,
|
||||
"text": chunk,
|
||||
"mrkdwn": True,
|
||||
}
|
||||
if blocks and i == 0:
|
||||
kwargs["blocks"] = blocks
|
||||
if thread_ts:
|
||||
kwargs["thread_ts"] = thread_ts
|
||||
# Only broadcast the first chunk of the first reply
|
||||
|
|
@ -1462,11 +1476,20 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
formatted = self.format_message(content)
|
||||
await self._get_client(chat_id).chat_update(
|
||||
channel=chat_id,
|
||||
ts=message_id,
|
||||
text=formatted,
|
||||
)
|
||||
update_kwargs: Dict[str, Any] = {
|
||||
"channel": chat_id,
|
||||
"ts": message_id,
|
||||
"text": formatted,
|
||||
}
|
||||
# Only render Block Kit on the FINAL edit. Intermediate streaming
|
||||
# edits stay plain mrkdwn — re-deriving a full block layout on every
|
||||
# progressive flush would be wasteful and jittery. ``text`` is kept
|
||||
# as the fallback either way.
|
||||
if finalize:
|
||||
blocks = self._maybe_blocks(content)
|
||||
if blocks:
|
||||
update_kwargs["blocks"] = blocks
|
||||
await self._get_client(chat_id).chat_update(**update_kwargs)
|
||||
if finalize:
|
||||
await self.stop_typing(chat_id)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
|
@ -1782,6 +1805,36 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
|
||||
# ----- Markdown → mrkdwn conversion -----
|
||||
|
||||
def _rich_blocks_enabled(self) -> bool:
|
||||
"""Whether to render outbound agent messages as Slack Block Kit blocks.
|
||||
|
||||
Opt-in via ``platforms.slack.extra.rich_blocks`` (config.yaml). Default
|
||||
off: messages continue to go out as flat mrkdwn ``text``. Enabling it
|
||||
renders the *final* agent message with real structural primitives
|
||||
(headers, dividers, true nested lists via ``rich_text``); tables are
|
||||
rendered as aligned monospace (Block Kit has no robust table block).
|
||||
"""
|
||||
raw = self.config.extra.get("rich_blocks")
|
||||
if raw is None:
|
||||
return False
|
||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def _maybe_blocks(self, content: str) -> Optional[list]:
|
||||
"""Render ``content`` to Block Kit blocks when the feature is enabled.
|
||||
|
||||
Returns ``None`` when rich blocks are disabled, or when the renderer
|
||||
declines (empty / too complex / unexpected shape) — the caller then
|
||||
falls back to the plain ``text`` payload. A ``text`` fallback is ALWAYS
|
||||
sent alongside blocks, so this can safely return ``None`` at any time.
|
||||
"""
|
||||
if not self._rich_blocks_enabled():
|
||||
return None
|
||||
try:
|
||||
return render_blocks(content, mrkdwn_fn=self.format_message)
|
||||
except Exception: # pragma: no cover - renderer already guards itself
|
||||
logger.debug("[Slack] block render failed; using plain text", exc_info=True)
|
||||
return None
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Convert standard markdown to Slack mrkdwn format.
|
||||
|
||||
|
|
|
|||
399
plugins/platforms/slack/block_kit.py
Normal file
399
plugins/platforms/slack/block_kit.py
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
"""Render agent markdown into Slack Block Kit blocks.
|
||||
|
||||
Opt-in (``slack.extra.rich_blocks: true``) alternative to the flat mrkdwn
|
||||
``text`` payload produced by :meth:`SlackAdapter.format_message`. Block Kit
|
||||
gives us real structural primitives — section headers, dividers, and true
|
||||
*nested* lists via ``rich_text`` — that plain mrkdwn can only approximate.
|
||||
|
||||
Design constraints (why this module is deliberately conservative):
|
||||
|
||||
* **Block Kit has no robust table primitive.** The newer ``table`` block is
|
||||
limited and fragile, so markdown pipe-tables are rendered as monospace
|
||||
``rich_text_preformatted`` — the same thing a human would paste today, just
|
||||
aligned. Proper ``table`` blocks are a future iteration (see the PR's Open
|
||||
Questions), not a v1 gate.
|
||||
* **Slack caps a message at 50 blocks** and a ``section``/text object at 3000
|
||||
characters. :func:`render_blocks` enforces both and, if the content simply
|
||||
cannot be expressed within them, returns ``None`` so the caller falls back
|
||||
to the plain-text path. A rich render is a nice-to-have; it must never lose
|
||||
a message.
|
||||
* **Every blocks payload MUST ship a ``text`` fallback.** Slack uses it for
|
||||
notifications, screen readers, and old clients. This module only builds the
|
||||
``blocks`` list; the adapter pairs it with the existing mrkdwn string.
|
||||
|
||||
The renderer never raises: any unexpected input degrades to ``None`` (caller
|
||||
uses plain text). It is a pure function of its input — no Slack client, no
|
||||
adapter state — so it is trivially unit-testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Slack Block Kit hard limits (https://docs.slack.dev/reference/block-kit/blocks)
|
||||
MAX_BLOCKS = 50
|
||||
MAX_SECTION_TEXT = 3000
|
||||
MAX_HEADER_TEXT = 150
|
||||
|
||||
Block = Dict[str, Any]
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Line classification
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
_HR_RE = re.compile(r"^\s{0,3}([-*_])(?:\s*\1){2,}\s*$")
|
||||
_HEADER_RE = re.compile(r"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$")
|
||||
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})(.*)$")
|
||||
_ORDERED_RE = re.compile(r"^(\s*)(\d+)[.)]\s+(.*)$")
|
||||
_BULLET_RE = re.compile(r"^(\s*)[-*+]\s+(.*)$")
|
||||
_QUOTE_RE = re.compile(r"^\s{0,3}>\s?(.*)$")
|
||||
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$")
|
||||
|
||||
|
||||
def _indent_level(spaces: str) -> int:
|
||||
"""Map leading whitespace to a nesting level (2 spaces or 1 tab per level)."""
|
||||
width = 0
|
||||
for ch in spaces:
|
||||
width += 4 if ch == "\t" else 1
|
||||
return min(width // 2, 5) # Slack rich_text_list supports up to indent 5
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Inline markdown → rich_text elements
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Order matters: code first (opaque), then links, then emphasis.
|
||||
_INLINE_CODE_RE = re.compile(r"`([^`]+)`")
|
||||
_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\(([^()\s]+(?:\([^()]*\)[^()\s]*)*)\)")
|
||||
_BOLD_RE = re.compile(r"(?:\*\*|__)(.+?)(?:\*\*|__)")
|
||||
_ITALIC_RE = re.compile(r"(?<![\*_])(?:\*|_)(?![\*_\s])(.+?)(?<![\*_\s])(?:\*|_)(?![\*_])")
|
||||
_STRIKE_RE = re.compile(r"~~(.+?)~~")
|
||||
|
||||
|
||||
def _inline_elements(text: str) -> List[Dict[str, Any]]:
|
||||
"""Parse a run of inline markdown into rich_text section child elements.
|
||||
|
||||
Produces ``text`` elements (optionally styled bold/italic/strike/code) and
|
||||
``link`` elements. Unmatched markup is emitted verbatim as plain text, so
|
||||
this never loses characters.
|
||||
"""
|
||||
elements: List[Dict[str, Any]] = []
|
||||
|
||||
def emit_text(s: str, style: Optional[Dict[str, bool]] = None) -> None:
|
||||
if not s:
|
||||
return
|
||||
el: Dict[str, Any] = {"type": "text", "text": s}
|
||||
if style:
|
||||
el["style"] = style
|
||||
elements.append(el)
|
||||
|
||||
# Tokenize by the highest-priority markers first using a single scan.
|
||||
# We recursively split on code, then links, then emphasis to keep spans
|
||||
# from overlapping incorrectly.
|
||||
def walk(s: str, style: Dict[str, bool]) -> None:
|
||||
pos = 0
|
||||
# inline code is opaque — no nested styling
|
||||
for m in _INLINE_CODE_RE.finditer(s):
|
||||
_walk_links(s[pos:m.start()], style)
|
||||
code_style = dict(style)
|
||||
code_style["code"] = True
|
||||
emit_text(m.group(1), code_style or None)
|
||||
pos = m.end()
|
||||
_walk_links(s[pos:], style)
|
||||
|
||||
def _walk_links(s: str, style: Dict[str, bool]) -> None:
|
||||
pos = 0
|
||||
for m in _LINK_RE.finditer(s):
|
||||
_walk_emphasis(s[pos:m.start()], style)
|
||||
link_el: Dict[str, Any] = {"type": "link", "url": m.group(2), "text": m.group(1)}
|
||||
if style:
|
||||
link_el["style"] = dict(style)
|
||||
elements.append(link_el)
|
||||
pos = m.end()
|
||||
_walk_emphasis(s[pos:], style)
|
||||
|
||||
def _walk_emphasis(s: str, style: Dict[str, bool]) -> None:
|
||||
if not s:
|
||||
return
|
||||
# Try bold, then strike, then italic, recursing into the inner span.
|
||||
for rx, key in ((_BOLD_RE, "bold"), (_STRIKE_RE, "strike"), (_ITALIC_RE, "italic")):
|
||||
m = rx.search(s)
|
||||
if m:
|
||||
_walk_emphasis(s[:m.start()], style)
|
||||
inner_style = dict(style)
|
||||
inner_style[key] = True
|
||||
_walk_emphasis(m.group(1), inner_style)
|
||||
_walk_emphasis(s[m.end():], style)
|
||||
return
|
||||
emit_text(s, dict(style) if style else None)
|
||||
|
||||
walk(text, {})
|
||||
return elements or [{"type": "text", "text": text}]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Structural block builders
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _header_block(text: str) -> Block:
|
||||
# header blocks are plain_text only, 150 char cap.
|
||||
clean = re.sub(r"[*_~`]", "", text).strip()
|
||||
if len(clean) > MAX_HEADER_TEXT:
|
||||
clean = clean[: MAX_HEADER_TEXT - 1] + "…"
|
||||
return {"type": "header", "text": {"type": "plain_text", "text": clean, "emoji": True}}
|
||||
|
||||
|
||||
def _divider_block() -> Block:
|
||||
return {"type": "divider"}
|
||||
|
||||
|
||||
def _preformatted_block(text: str) -> Block:
|
||||
# rich_text_preformatted renders monospace; used for code fences + tables.
|
||||
return {
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"elements": [{"type": "text", "text": text.rstrip("\n")}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _quote_block(lines: List[str]) -> Block:
|
||||
section_children: List[Dict[str, Any]] = []
|
||||
for i, ln in enumerate(lines):
|
||||
if i:
|
||||
section_children.append({"type": "text", "text": "\n"})
|
||||
section_children.extend(_inline_elements(ln))
|
||||
return {
|
||||
"type": "rich_text",
|
||||
"elements": [{"type": "rich_text_quote", "elements": section_children}],
|
||||
}
|
||||
|
||||
|
||||
def _list_block(items: List[Tuple[int, bool, str]]) -> Block:
|
||||
"""Build ONE rich_text block from consecutive list items.
|
||||
|
||||
``items`` is a list of ``(indent, ordered, text)``. Each contiguous run
|
||||
sharing the same (indent, ordered) becomes a ``rich_text_list`` element;
|
||||
indentation changes start a new element, which is how Slack renders true
|
||||
nesting.
|
||||
"""
|
||||
elements: List[Dict[str, Any]] = []
|
||||
cur: Optional[Dict[str, Any]] = None
|
||||
cur_key: Optional[Tuple[int, bool]] = None
|
||||
for indent, ordered, text in items:
|
||||
key = (indent, ordered)
|
||||
if key != cur_key:
|
||||
cur = {
|
||||
"type": "rich_text_list",
|
||||
"style": "ordered" if ordered else "bullet",
|
||||
"indent": indent,
|
||||
"elements": [],
|
||||
}
|
||||
elements.append(cur)
|
||||
cur_key = key
|
||||
assert cur is not None
|
||||
cur["elements"].append(
|
||||
{"type": "rich_text_section", "elements": _inline_elements(text)}
|
||||
)
|
||||
return {"type": "rich_text", "elements": elements}
|
||||
|
||||
|
||||
def _section_block(text: str) -> Block:
|
||||
return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Table handling (best-effort monospace fallback)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_table(rows: List[str]) -> str:
|
||||
"""Render markdown pipe-table rows as aligned monospace text."""
|
||||
parsed: List[List[str]] = []
|
||||
for r in rows:
|
||||
cells = [c.strip() for c in r.strip().strip("|").split("|")]
|
||||
parsed.append(cells)
|
||||
if not parsed:
|
||||
return "\n".join(rows)
|
||||
ncols = max(len(r) for r in parsed)
|
||||
for r in parsed:
|
||||
r.extend([""] * (ncols - len(r)))
|
||||
widths = [max(len(r[c]) for r in parsed) for c in range(ncols)]
|
||||
out_lines = []
|
||||
for ri, r in enumerate(parsed):
|
||||
line = " | ".join(r[c].ljust(widths[c]) for c in range(ncols))
|
||||
out_lines.append(line.rstrip())
|
||||
if ri == 0: # header underline
|
||||
out_lines.append("-+-".join("-" * widths[c] for c in range(ncols)))
|
||||
return "\n".join(out_lines)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_blocks(
|
||||
markdown: str,
|
||||
mrkdwn_fn=None,
|
||||
) -> Optional[List[Block]]:
|
||||
"""Convert agent markdown to a Slack Block Kit ``blocks`` list.
|
||||
|
||||
Args:
|
||||
markdown: The agent's response text (standard markdown).
|
||||
mrkdwn_fn: Optional callable converting a markdown paragraph to Slack
|
||||
mrkdwn for ``section`` blocks (the adapter passes
|
||||
``format_message``). When ``None``, the raw paragraph text is used.
|
||||
|
||||
Returns:
|
||||
A list of Block Kit block dicts, or ``None`` when the content is empty,
|
||||
exceeds Slack's structural limits, or hits an unexpected shape — the
|
||||
caller then falls back to the flat ``text`` payload. Never raises.
|
||||
"""
|
||||
if not markdown or not markdown.strip():
|
||||
return None
|
||||
|
||||
fmt = mrkdwn_fn or (lambda s: s)
|
||||
|
||||
try:
|
||||
blocks: List[Block] = []
|
||||
lines = markdown.replace("\r\n", "\n").split("\n")
|
||||
i = 0
|
||||
n = len(lines)
|
||||
para: List[str] = []
|
||||
|
||||
def flush_para() -> None:
|
||||
if not para:
|
||||
return
|
||||
text = "\n".join(para).strip()
|
||||
para.clear()
|
||||
if not text:
|
||||
return
|
||||
rendered = fmt(text)
|
||||
# Split oversized sections on the 3000-char limit.
|
||||
for chunk in _split_text(rendered, MAX_SECTION_TEXT):
|
||||
blocks.append(_section_block(chunk))
|
||||
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
|
||||
# Blank line: paragraph boundary
|
||||
if not line.strip():
|
||||
flush_para()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Fenced code block
|
||||
fence = _FENCE_RE.match(line)
|
||||
if fence:
|
||||
flush_para()
|
||||
marker = fence.group(1)
|
||||
body: List[str] = []
|
||||
i += 1
|
||||
while i < n and not lines[i].lstrip().startswith(marker):
|
||||
body.append(lines[i])
|
||||
i += 1
|
||||
i += 1 # consume closing fence
|
||||
blocks.append(_preformatted_block("\n".join(body)))
|
||||
continue
|
||||
|
||||
# Horizontal rule → divider
|
||||
if _HR_RE.match(line):
|
||||
flush_para()
|
||||
blocks.append(_divider_block())
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# ATX header
|
||||
hm = _HEADER_RE.match(line)
|
||||
if hm:
|
||||
flush_para()
|
||||
blocks.append(_header_block(hm.group(2)))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Pipe table: current line has a pipe AND next line is a separator
|
||||
if "|" in line and i + 1 < n and _TABLE_SEP_RE.match(lines[i + 1]):
|
||||
flush_para()
|
||||
trows = [line]
|
||||
i += 2 # skip header + separator
|
||||
while i < n and "|" in lines[i] and lines[i].strip():
|
||||
trows.append(lines[i])
|
||||
i += 1
|
||||
blocks.append(_preformatted_block(_render_table(trows)))
|
||||
continue
|
||||
|
||||
# Blockquote group
|
||||
if _QUOTE_RE.match(line):
|
||||
flush_para()
|
||||
qlines: List[str] = []
|
||||
while i < n:
|
||||
qm = _QUOTE_RE.match(lines[i])
|
||||
if not qm:
|
||||
break
|
||||
qlines.append(qm.group(1))
|
||||
i += 1
|
||||
blocks.append(_quote_block(qlines))
|
||||
continue
|
||||
|
||||
# List group (bullets + ordered, with nesting)
|
||||
if _BULLET_RE.match(line) or _ORDERED_RE.match(line):
|
||||
flush_para()
|
||||
items: List[Tuple[int, bool, str]] = []
|
||||
while i < n:
|
||||
bm = _BULLET_RE.match(lines[i])
|
||||
om = _ORDERED_RE.match(lines[i])
|
||||
if bm:
|
||||
items.append((_indent_level(bm.group(1)), False, bm.group(2)))
|
||||
i += 1
|
||||
elif om:
|
||||
items.append((_indent_level(om.group(1)), True, om.group(3)))
|
||||
i += 1
|
||||
elif lines[i].strip() and lines[i].startswith((" ", "\t")) and items:
|
||||
# continuation line of the previous item
|
||||
indent, ordered, txt = items[-1]
|
||||
items[-1] = (indent, ordered, txt + " " + lines[i].strip())
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
blocks.append(_list_block(items))
|
||||
continue
|
||||
|
||||
# Default: accumulate into a paragraph
|
||||
para.append(line)
|
||||
i += 1
|
||||
|
||||
flush_para()
|
||||
|
||||
if not blocks:
|
||||
return None
|
||||
if len(blocks) > MAX_BLOCKS:
|
||||
# Too structurally complex to express safely — let the caller fall
|
||||
# back to plain text rather than truncating and losing content.
|
||||
return None
|
||||
return blocks
|
||||
except Exception:
|
||||
# Never let a rendering bug drop a message.
|
||||
return None
|
||||
|
||||
|
||||
def _split_text(text: str, limit: int) -> List[str]:
|
||||
"""Split ``text`` into <= ``limit``-char chunks on line, then hard, boundaries."""
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
out: List[str] = []
|
||||
remaining = text
|
||||
while len(remaining) > limit:
|
||||
cut = remaining.rfind("\n", 0, limit)
|
||||
if cut <= 0:
|
||||
cut = limit
|
||||
out.append(remaining[:cut])
|
||||
remaining = remaining[cut:].lstrip("\n")
|
||||
if remaining:
|
||||
out.append(remaining)
|
||||
return out
|
||||
Loading…
Add table
Add a link
Reference in a new issue