mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-18 14:52:04 +00:00
feat(slack): render markdown tables as native Block Kit table blocks
Replace the interim monospace table fallback with Slack's native `table` block (rows of rich_text cells). Addresses the core ask in #18918. - _table_block(): builds type:"table" with rich_text cells, so inline formatting (bold, links, code) renders inside cells. - Column alignment parsed from the markdown separator row (:---, :-:, --:) into column_settings (left = default/null-skip, center/right emitted). - Escaped pipes (\\|) are not treated as column separators. - Respects Slack's table limits (100 rows / 20 cols / 10k aggregate chars); oversized or unparseable tables gracefully fall back to aligned monospace (rich_text_preformatted), so a big table never breaks the message. Docs (EN + zh-Hans) updated to describe native tables + the fallback. Tests: native table shape, alignment->column_settings, inline-formatted cells, oversized/too-wide monospace fallback, escaped-pipe cell. Prove- failed against a stubbed _table_block (native-table tests fail, fallback tests stay green). All existing Slack tests still pass.
This commit is contained in:
parent
b080b93ad8
commit
7c7b489813
4 changed files with 174 additions and 22 deletions
|
|
@ -35,6 +35,10 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||
MAX_BLOCKS = 50
|
||||
MAX_SECTION_TEXT = 3000
|
||||
MAX_HEADER_TEXT = 150
|
||||
# Native table block limits (https://docs.slack.dev/reference/block-kit/blocks/table-block)
|
||||
MAX_TABLE_ROWS = 100
|
||||
MAX_TABLE_COLS = 20
|
||||
MAX_TABLE_CHARS = 10000 # aggregate across all cells
|
||||
|
||||
Block = Dict[str, Any]
|
||||
|
||||
|
|
@ -208,15 +212,95 @@ def _section_block(text: str) -> Block:
|
|||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Table handling (best-effort monospace fallback)
|
||||
# Table handling — native Block Kit ``table`` block, monospace fallback
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_alignment(sep_line: str) -> List[str]:
|
||||
"""Parse a markdown separator row (``|:--|:-:|--:|``) into column aligns.
|
||||
|
||||
Returns a list of ``"left"``/``"center"``/``"right"`` per column.
|
||||
"""
|
||||
aligns: List[str] = []
|
||||
for cell in sep_line.strip().strip("|").split("|"):
|
||||
c = cell.strip()
|
||||
left = c.startswith(":")
|
||||
right = c.endswith(":")
|
||||
if left and right:
|
||||
aligns.append("center")
|
||||
elif right:
|
||||
aligns.append("right")
|
||||
else:
|
||||
aligns.append("left")
|
||||
return aligns
|
||||
|
||||
|
||||
def _split_row(row: str) -> List[str]:
|
||||
"""Split a markdown table row into trimmed cell strings.
|
||||
|
||||
Respects backslash-escaped pipes (``\\|``) so they aren't treated as
|
||||
column separators.
|
||||
"""
|
||||
# Temporarily protect escaped pipes, split on real ones, then restore.
|
||||
protected = row.strip().strip("|").replace(r"\|", "\x00PIPE\x00")
|
||||
return [c.strip().replace("\x00PIPE\x00", "|") for c in protected.split("|")]
|
||||
|
||||
|
||||
def _rich_text_cell(text: str) -> Dict[str, Any]:
|
||||
"""A ``rich_text`` table cell carrying inline-formatted content."""
|
||||
return {
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{"type": "rich_text_section", "elements": _inline_elements(text)}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _table_block(rows: List[str], sep_line: str) -> Optional[Block]:
|
||||
"""Build a native Slack ``table`` block from markdown pipe-table rows.
|
||||
|
||||
``rows`` includes the header row (index 0) and body rows; ``sep_line`` is
|
||||
the ``|---|`` alignment row (already consumed by the caller). Returns
|
||||
``None`` when the table exceeds Slack's limits (100 rows / 20 cols /
|
||||
10,000 aggregate cell chars) or parses to nothing — the caller then falls
|
||||
back to the monospace preformatted rendering.
|
||||
"""
|
||||
parsed = [_split_row(r) for r in rows if r.strip()]
|
||||
if not parsed:
|
||||
return None
|
||||
ncols = max(len(r) for r in parsed)
|
||||
# Reject rather than silently truncate beyond Slack's structural limits.
|
||||
if len(parsed) > MAX_TABLE_ROWS or ncols > MAX_TABLE_COLS:
|
||||
return None
|
||||
for r in parsed:
|
||||
r.extend([""] * (ncols - len(r)))
|
||||
|
||||
total_chars = sum(len(c) for r in parsed for c in r)
|
||||
if total_chars > MAX_TABLE_CHARS:
|
||||
return None
|
||||
|
||||
aligns = _parse_alignment(sep_line)
|
||||
column_settings: List[Optional[Dict[str, Any]]] = []
|
||||
for c in range(min(ncols, MAX_TABLE_COLS)):
|
||||
align = aligns[c] if c < len(aligns) else "left"
|
||||
# Only emit a setting when it differs from the default (left, no wrap);
|
||||
# use null to skip a column, per the Slack schema.
|
||||
column_settings.append({"align": align} if align != "left" else None)
|
||||
|
||||
block: Block = {
|
||||
"type": "table",
|
||||
"rows": [[_rich_text_cell(cell) for cell in row] for row in parsed],
|
||||
}
|
||||
if any(cs is not None for cs in column_settings):
|
||||
block["column_settings"] = column_settings
|
||||
return block
|
||||
|
||||
|
||||
def _render_table(rows: List[str]) -> str:
|
||||
"""Render markdown pipe-table rows as aligned monospace text."""
|
||||
"""Render markdown pipe-table rows as aligned monospace text (fallback)."""
|
||||
parsed: List[List[str]] = []
|
||||
for r in rows:
|
||||
cells = [c.strip() for c in r.strip().strip("|").split("|")]
|
||||
cells = _split_row(r)
|
||||
parsed.append(cells)
|
||||
if not parsed:
|
||||
return "\n".join(rows)
|
||||
|
|
@ -320,12 +404,20 @@ def render_blocks(
|
|||
# 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]
|
||||
header_row = line
|
||||
sep_line = lines[i + 1]
|
||||
trows = [header_row]
|
||||
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)))
|
||||
# Prefer a native Block Kit table; fall back to aligned
|
||||
# monospace when it exceeds Slack's table limits or won't parse.
|
||||
table = _table_block(trows, sep_line)
|
||||
if table is not None:
|
||||
blocks.append(table)
|
||||
else:
|
||||
blocks.append(_preformatted_block(_render_table(trows)))
|
||||
continue
|
||||
|
||||
# Blockquote group
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class TestInlineFormatting:
|
|||
|
||||
|
||||
class TestTables:
|
||||
def test_pipe_table_renders_preformatted(self):
|
||||
def test_pipe_table_renders_native_table_block(self):
|
||||
md = (
|
||||
"| Name | Status |\n"
|
||||
"|------|--------|\n"
|
||||
|
|
@ -101,13 +101,72 @@ class TestTables:
|
|||
)
|
||||
blocks = render_blocks(md)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["type"] == "table"
|
||||
rows = blocks[0]["rows"]
|
||||
# header + 2 body rows, 2 columns each
|
||||
assert len(rows) == 3
|
||||
assert all(len(r) == 2 for r in rows)
|
||||
# cells are rich_text carrying the values
|
||||
assert str(rows[0]).count("Name") == 1
|
||||
assert "fail" in str(rows[2])
|
||||
|
||||
def test_alignment_parsed_into_column_settings(self):
|
||||
md = (
|
||||
"| L | C | R |\n"
|
||||
"|:---|:--:|---:|\n"
|
||||
"| 1 | 2 | 3 |"
|
||||
)
|
||||
blocks = render_blocks(md)
|
||||
cs = blocks[0]["column_settings"]
|
||||
# left is default -> null; center/right emitted
|
||||
assert cs[0] is None
|
||||
assert cs[1] == {"align": "center"}
|
||||
assert cs[2] == {"align": "right"}
|
||||
|
||||
def test_inline_formatting_inside_cells(self):
|
||||
md = (
|
||||
"| Item | Link |\n"
|
||||
"|------|------|\n"
|
||||
"| **bold** | [x](https://e.io) |"
|
||||
)
|
||||
blocks = render_blocks(md)
|
||||
body = blocks[0]["rows"][1]
|
||||
# bold styled text element in first cell
|
||||
bold = [
|
||||
el for el in body[0]["elements"][0]["elements"]
|
||||
if el.get("style", {}).get("bold")
|
||||
]
|
||||
assert bold
|
||||
# link element in second cell
|
||||
links = [el for el in body[1]["elements"][0]["elements"] if el["type"] == "link"]
|
||||
assert links and links[0]["url"] == "https://e.io"
|
||||
|
||||
def test_oversized_table_falls_back_to_monospace(self):
|
||||
# 120 rows > MAX_TABLE_ROWS -> monospace rich_text fallback, not a table
|
||||
big = "| a | b |\n|---|---|\n" + "\n".join(f"| x{i} | y |" for i in range(120))
|
||||
blocks = render_blocks(big)
|
||||
assert blocks[0]["type"] == "rich_text" # preformatted fallback
|
||||
assert blocks[0]["elements"][0]["type"] == "rich_text_preformatted"
|
||||
|
||||
def test_too_many_columns_falls_back_to_monospace(self):
|
||||
header = "|" + "|".join(f"c{i}" for i in range(25)) + "|"
|
||||
sep = "|" + "|".join("-" for _ in range(25)) + "|"
|
||||
row = "|" + "|".join("v" for _ in range(25)) + "|"
|
||||
blocks = render_blocks(f"{header}\n{sep}\n{row}")
|
||||
assert blocks[0]["type"] == "rich_text"
|
||||
pre = blocks[0]["elements"][0]
|
||||
assert pre["type"] == "rich_text_preformatted"
|
||||
text = pre["elements"][0]["text"]
|
||||
# header cell values preserved and column aligned
|
||||
assert "Name" in text and "Status" in text
|
||||
assert "fail" in text
|
||||
|
||||
def test_escaped_pipe_not_a_column_separator(self):
|
||||
md = (
|
||||
"| Expr | Meaning |\n"
|
||||
"|------|--------|\n"
|
||||
"| a \\| b | or |"
|
||||
)
|
||||
blocks = render_blocks(md)
|
||||
assert blocks[0]["type"] == "table"
|
||||
# the escaped-pipe cell stays a single cell containing a literal pipe
|
||||
body = blocks[0]["rows"][1]
|
||||
assert len(body) == 2
|
||||
assert "|" in str(body[0])
|
||||
|
||||
|
||||
class TestLimits:
|
||||
|
|
|
|||
|
|
@ -346,10 +346,11 @@ platforms:
|
|||
|
||||
# Render agent messages as Slack Block Kit blocks (default: false).
|
||||
# When true, the final agent message is sent with structured blocks —
|
||||
# section headers, dividers, and true nested lists (via rich_text) —
|
||||
# instead of flat mrkdwn text. A plain-text fallback is always sent
|
||||
# alongside for notifications/accessibility. Markdown tables are
|
||||
# rendered as aligned monospace (Block Kit has no native table block).
|
||||
# section headers, dividers, true nested lists (via rich_text), and
|
||||
# native Block Kit tables — instead of flat mrkdwn text. A plain-text
|
||||
# fallback is always sent alongside for notifications/accessibility.
|
||||
# Tables exceeding Slack's limits (100 rows / 20 cols / 10k chars)
|
||||
# gracefully fall back to aligned monospace.
|
||||
rich_blocks: false
|
||||
```
|
||||
|
||||
|
|
@ -358,7 +359,7 @@ platforms:
|
|||
| `platforms.slack.reply_to_mode` | `"first"` | Threading mode for multi-part messages: `"off"`, `"first"`, or `"all"` |
|
||||
| `platforms.slack.extra.reply_in_thread` | `true` | When `false`, channel messages get direct replies instead of threads. Messages inside existing threads still reply in-thread. |
|
||||
| `platforms.slack.extra.reply_broadcast` | `false` | When `true`, thread replies are also posted to the main channel. Only the first chunk is broadcast. |
|
||||
| `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists). A plain-text fallback is always sent. Tables render as aligned monospace. No app reinstall required — it's a send-side change only. |
|
||||
| `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists, and native tables). A plain-text fallback is always sent. Tables over Slack's limits fall back to aligned monospace. No app reinstall required — it's a send-side change only. |
|
||||
|
||||
### Session Isolation
|
||||
|
||||
|
|
|
|||
|
|
@ -301,10 +301,10 @@ platforms:
|
|||
|
||||
# 将 Agent 消息渲染为 Slack Block Kit 区块(默认:false)。
|
||||
# 为 true 时,最终的 Agent 消息会以结构化区块发送——包括
|
||||
# 章节标题、分隔线以及真正的嵌套列表(通过 rich_text)——
|
||||
# 而非扁平的 mrkdwn 文本。同时始终附带纯文本回退内容,
|
||||
# 用于通知和无障碍访问。Markdown 表格会渲染为对齐的等宽文本
|
||||
# (Block Kit 没有原生表格区块)。
|
||||
# 章节标题、分隔线、真正的嵌套列表(通过 rich_text)以及
|
||||
# 原生 Block Kit 表格——而非扁平的 mrkdwn 文本。同时始终附带
|
||||
# 纯文本回退内容,用于通知和无障碍访问。超出 Slack 限制
|
||||
# (100 行 / 20 列 / 1 万字符)的表格会优雅地回退为对齐的等宽文本。
|
||||
rich_blocks: false
|
||||
```
|
||||
|
||||
|
|
@ -313,7 +313,7 @@ platforms:
|
|||
| `platforms.slack.reply_to_mode` | `"first"` | 多部分消息的话题模式:`"off"`、`"first"` 或 `"all"` |
|
||||
| `platforms.slack.extra.reply_in_thread` | `true` | 为 `false` 时,频道消息直接回复而非话题。已在话题中的消息仍在话题中回复。 |
|
||||
| `platforms.slack.extra.reply_broadcast` | `false` | 为 `true` 时,话题回复也会发布到主频道。仅广播第一个分块。 |
|
||||
| `platforms.slack.extra.rich_blocks` | `false` | 为 `true` 时,Agent 消息会渲染为 [Block Kit](https://docs.slack.dev/block-kit/) 区块(标题、分隔线、真正的嵌套列表)。始终附带纯文本回退。表格渲染为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 |
|
||||
| `platforms.slack.extra.rich_blocks` | `false` | 为 `true` 时,Agent 消息会渲染为 [Block Kit](https://docs.slack.dev/block-kit/) 区块(标题、分隔线、真正的嵌套列表以及原生表格)。始终附带纯文本回退。超出 Slack 限制的表格会回退为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 |
|
||||
|
||||
### 会话隔离
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue