@inline-chat/hermes-agent-adapter 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,15 @@ from typing import Any, Dict, Iterable, Optional
12
12
 
13
13
  from .message_actions import build_inline_agent_action_id
14
14
  from .profile import hermes_home, inline_env, multiplex_active
15
+ from .widgets import (
16
+ MAX_MESSAGE_TEXT_UTF16_UNITS,
17
+ MAX_MESSAGE_TEXT_UTF8_BYTES,
18
+ WidgetError,
19
+ build_widget_markdown,
20
+ has_widget_markup,
21
+ scan_widgets,
22
+ utf16_len,
23
+ )
15
24
 
16
25
  try:
17
26
  from tools.registry import tool_error, tool_result
@@ -71,12 +80,25 @@ INLINE_PLATFORM_GUIDANCE = (
71
80
  "do not treat thread ids as reply/quote message ids. Supported Markdown is "
72
81
  "emphasis, inline or fenced code, links, headings, lists or checklists, "
73
82
  "quotes, tables, separators, and HTTP(S) images. Do not rely on "
74
- "strikethrough, footnotes, or arbitrary HTML; unsupported or ambiguous "
83
+ "strikethrough, footnotes, or arbitrary HTML outside widgets; unsupported or ambiguous "
75
84
  "incomplete syntax remains visible text. Use ordinary Markdown "
76
85
  "tables for tabular data, never fenced-code tables. For collapsible work, "
77
86
  "use <details open>, then <summary>Title</summary>, body Markdown, and "
78
87
  "</details>; put kind=\"progress\" on summary only while work is in progress. "
79
- "Use <footer>Attribution or brief metadata</footer> for a short footer."
88
+ "Use <footer>Attribution or brief metadata</footer> for a short footer. "
89
+ "Widgets: one sandboxed HTML block per message, only when Markdown or tables can't show it "
90
+ "(charts, diagrams, mockups, comparison grids, small pickers); keep prose outside it. Prefer "
91
+ "the inline tool's send_widget action; in a reply, a block on its own lines works too: "
92
+ "<iframe title=\"Name\" id=\"name\" height=\"420\">HTML</iframe> (one widget per message; "
93
+ "height 80-2000 px, about the content height at 640px wide). The sandbox has no network "
94
+ "(no fetch, XHR, WebSocket or remote images; use data: URIs); scripts may be inline or from "
95
+ "cdn.jsdelivr.net, cdnjs.cloudflare.com, unpkg.com or esm.sh. Avoid vh units, position: fixed "
96
+ "and nested frames; fit 320-640px widths. Theme CSS variables: --background --foreground "
97
+ "--muted --muted-foreground --card --border --primary --primary-foreground --accent --success "
98
+ "--warning --destructive --radius --font-sans --font-mono; classes .card .grid .row .badge "
99
+ ".btn .btn-primary .btn-ghost .table .muted .small. After a viewer's click, scripts can call "
100
+ "inline.copy(text), inline.sendPrompt(text), inline.action(name, payload) (reaches you as a "
101
+ "widget call turn), inline.openLink(url), and inline.setState(obj) with inline.state."
80
102
  )
81
103
 
82
104
  # Where each turn's triggering message physically lives, and who sent it. Hermes tells tools
@@ -129,7 +151,8 @@ _sidecars: Dict[str, Dict[str, Any]] = {}
129
151
  _settings_owners: Dict[str, Any] = {}
130
152
 
131
153
  _ACTION_MANIFEST = [
132
- ("send_message", "(chat_id?|user_id?, text, buttons?)", "Send a message, including optional agent-owned buttons."),
154
+ ("send_message", "(chat_id?|user_id?, text, buttons?, reply_to_message_id?)", "Send a message, including optional agent-owned buttons."),
155
+ ("send_widget", "(chat_id?|user_id?, title, html, widget_id?, height?, text?, reply_to_message_id?)", "Send one sandboxed HTML widget, with optional lead text above it."),
133
156
  ("get_chat", "(chat_id?)", "Get metadata for a chat or reply thread, including whether you follow it."),
134
157
  ("set_follow_mode", "(chat_id?, mode)", "Follow a thread to join its conversation, or unfollow it to answer only mentions and replies. Applies to you only; mode is following or unfollowed."),
135
158
  ("get_messages", "(chat_id?, message_ids)", "Fetch specific Inline messages by ID."),
@@ -238,6 +261,7 @@ def tool_context_prompt(
238
261
  lines = [
239
262
  "- The `inline` tool is available for Inline history, search, exact message lookup, reactions, pins, and explicit thread management.",
240
263
  "- Return normal replies as text. Use inline send_message only to create a message with action buttons or to send into a different destination.",
264
+ "- For a chart, diagram, mockup or picker that Markdown can't show, use inline send_widget (then reply NO_REPLY unless more text is needed). A widget call from a viewer arrives as a turn naming widget_id, call_name and untrusted payload_json.",
241
265
  "- An Inline action-button turn names its source message. A normal response replaces that message; omitted buttons clear the old buttons. Use edit_message with buttons to replace them explicitly.",
242
266
  "- If edit_message already finalized the source action card during this turn, return NO_REPLY so the automatic final response does not overwrite that edit.",
243
267
  ]
@@ -270,6 +294,7 @@ INLINE_TOOL_SCHEMA = {
270
294
  + "\n\n"
271
295
  "When chat_id is omitted, the tool uses the current Inline reply thread if present, otherwise the current chat. "
272
296
  "Return normal assistant replies as text. Use send_message only for a button card or a different destination. "
297
+ "Use send_widget for one sandboxed HTML widget (see the Inline platform guidance); it is sent whole, and Inline's reason is returned if it rejects the widget. "
273
298
  "To hand work to another bot, mention it in your normal reply; its answer comes back to you as a turn that doesn't need a response, and asking it again takes a new mention. "
274
299
  "Agent-owned button presses return as ordinary turns, and the normal response replaces the source card while clearing omitted buttons. "
275
300
  "Use create_thread on the current triggering message when someone asks to continue in a thread; your answer then goes into it, so don't also post it in the chat, and it starts with a short summary of the conversation so far. "
@@ -300,7 +325,11 @@ INLINE_TOOL_SCHEMA = {
300
325
  "type": "string",
301
326
  "description": "Parent message ID for create_thread. Defaults to the triggering message when available.",
302
327
  },
303
- "title": {"type": "string", "description": "Thread title. Required for create_chat, optional for create_thread, and the new name for update_chat."},
328
+ "title": {"type": "string", "description": "Thread title. Required for create_chat, optional for create_thread, and the new name for update_chat. For send_widget, the widget's title."},
329
+ "html": {"type": "string", "description": "send_widget: the widget's HTML body (no <iframe> wrapper, no </iframe>)."},
330
+ "widget_id": {"type": "string", "description": "send_widget: widget id, 1-64 of A-Z a-z 0-9 _ . : - (default widget). Widget calls name it."},
331
+ "height": {"type": "integer", "minimum": 80, "maximum": 2000, "description": "send_widget: frame height in CSS px, about the content height at 640px wide."},
332
+ "reply_to_message_id": {"type": "string", "description": "send_message/send_widget: message ID to reply to."},
304
333
  "name": {"type": "string", "description": "Agent name for create_agent."},
305
334
  "agent_id": {"type": "string", "description": "Globally unique Inline Agent ID for get_agent, update_agent, or delete_agent."},
306
335
  "handle": {"type": "string", "description": "Optional Agent handle."},
@@ -320,7 +349,7 @@ INLINE_TOOL_SCHEMA = {
320
349
  "description": "Whether create_chat is visible to the parent space. Defaults to false and requires space_id when true.",
321
350
  },
322
351
  "query": {"type": "string", "description": "Search query for search_messages."},
323
- "text": {"type": "string", "description": "Message text for send_message or edit_message."},
352
+ "text": {"type": "string", "description": "Message text for send_message or edit_message; optional lead text above a send_widget widget."},
324
353
  "buttons": {
325
354
  "type": "array",
326
355
  "maxItems": _MAX_ACTION_ROWS,
@@ -387,7 +416,13 @@ def _handle_inline_tool(args: Dict[str, Any], **_: Any) -> str:
387
416
  if action == "create_thread":
388
417
  return tool_result({"success": True, "action": action, "result": _create_thread(args)})
389
418
  path, body = _request_for_action(action, args)
390
- response = _sidecar_call(path, body)
419
+ try:
420
+ response = _sidecar_call(path, body)
421
+ except InlineToolError as exc:
422
+ if action == "send_widget" and exc.error_kind == "bad_format":
423
+ # The server names the widget rule the message broke; the model can fix and resend.
424
+ raise InlineToolError(f"Inline rejected the widget: {exc}", exc.error_kind) from exc
425
+ raise
391
426
  result = _compact_result(action, response.get("result") or {})
392
427
  return tool_result({"success": True, "action": action, "result": result})
393
428
  except InlineToolError as exc:
@@ -396,14 +431,37 @@ def _handle_inline_tool(args: Dict[str, Any], **_: Any) -> str:
396
431
 
397
432
  def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
398
433
  if action == "send_message":
434
+ parse_markdown = _bool(args.get("parse_markdown"), True)
399
435
  body = {
400
436
  "target": _target(args),
401
- "text": _required_str(args, "text", max_chars=_MAX_TEXT_CHARS),
402
- "parseMarkdown": _bool(args.get("parse_markdown"), True),
437
+ "text": _message_text(args, parse_markdown),
438
+ "parseMarkdown": parse_markdown,
403
439
  }
404
440
  actions = _message_actions(args)
405
441
  if actions is not None:
406
442
  body["actions"] = actions
443
+ reply_to = _inline_id(args.get("reply_to_message_id"))
444
+ if reply_to:
445
+ body["replyToMsgId"] = reply_to
446
+ return "/send", body
447
+
448
+ if action == "send_widget":
449
+ if not _str(args.get("title")):
450
+ raise InlineToolError("title is required", "bad_format")
451
+ try:
452
+ text = build_widget_markdown(
453
+ html=str(args.get("html") or ""),
454
+ title=_str(args.get("title")),
455
+ widget_id=_str(args.get("widget_id")),
456
+ height=args.get("height"),
457
+ text=str(args.get("text") or ""),
458
+ )
459
+ except WidgetError as exc:
460
+ raise InlineToolError(str(exc), "bad_format") from exc
461
+ body = {"target": _target(args), "text": text, "parseMarkdown": True}
462
+ reply_to = _inline_id(args.get("reply_to_message_id"))
463
+ if reply_to:
464
+ body["replyToMsgId"] = reply_to
407
465
  return "/send", body
408
466
 
409
467
  if action == "get_chat":
@@ -437,11 +495,12 @@ def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[st
437
495
  return "/search", body
438
496
 
439
497
  if action == "edit_message":
498
+ parse_markdown = _bool(args.get("parse_markdown"), True)
440
499
  body = {
441
500
  "target": _target(args),
442
501
  "messageId": _required_id(args, "message_id"),
443
- "text": _required_str(args, "text", max_chars=_MAX_TEXT_CHARS),
444
- "parseMarkdown": _bool(args.get("parse_markdown"), True),
502
+ "text": _message_text(args, parse_markdown),
503
+ "parseMarkdown": parse_markdown,
445
504
  }
446
505
  actions = _message_actions(args)
447
506
  if actions is not None:
@@ -895,7 +954,7 @@ def _message_actions(args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
895
954
 
896
955
 
897
956
  def _compact_result(action: str, result: Dict[str, Any]) -> Dict[str, Any]:
898
- if action in {"send_message", "edit_message"}:
957
+ if action in {"send_message", "send_widget", "edit_message"}:
899
958
  return {"messageId": _str(result.get("messageId"))}
900
959
  if action == "get_chat":
901
960
  return _compact_chat(result)
@@ -1166,6 +1225,18 @@ def _required_str(args: Dict[str, Any], key: str, *, max_chars: int) -> str:
1166
1225
  return _truncate(value, max_chars)
1167
1226
 
1168
1227
 
1228
+ def _message_text(args: Dict[str, Any], parse_markdown: bool) -> str:
1229
+ """Message text, shortened as before unless it holds a widget, which is sent whole or refused."""
1230
+ value = _str(args.get("text"))
1231
+ if not value:
1232
+ raise InlineToolError("text is required", "bad_format")
1233
+ if not parse_markdown or not has_widget_markup(value) or not scan_widgets(value).widgets:
1234
+ return _truncate(value, _MAX_TEXT_CHARS)
1235
+ if utf16_len(value) > MAX_MESSAGE_TEXT_UTF16_UNITS or len(value.encode("utf-8")) > MAX_MESSAGE_TEXT_UTF8_BYTES:
1236
+ raise InlineToolError("message with a widget exceeds Inline's 100,000 character limit", "bad_format")
1237
+ return value
1238
+
1239
+
1169
1240
  def _required_id(args: Dict[str, Any], key: str) -> str:
1170
1241
  value = _inline_id(args.get(key))
1171
1242
  if not value:
@@ -0,0 +1,267 @@
1
+ """Inline message widgets: AFM ``<iframe>`` previews in bot Markdown.
2
+
3
+ The server turns one ``<iframe title=… id=… height=…>HTML</iframe>`` block in a
4
+ bot's Markdown into a sandboxed widget. These helpers mirror its parsing rules
5
+ so outbound text is never split inside a widget, and build the Markdown for the
6
+ ``send_widget`` tool action.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import html as _html
11
+ import re
12
+ from typing import Any, Callable, List, NamedTuple, Optional
13
+
14
+
15
+ # Server limits (server/src/modules/message/messageWidgets.ts, messageTextLimits.ts).
16
+ MAX_MESSAGE_TEXT_UTF16_UNITS = 100_000
17
+ MAX_MESSAGE_TEXT_UTF8_BYTES = 400_000
18
+ MAX_WIDGET_HTML_BYTES = 256 * 1024
19
+ MAX_WIDGET_TITLE_CHARS = 256
20
+ MIN_WIDGET_HEIGHT = 80
21
+ MAX_WIDGET_HEIGHT = 2000
22
+
23
+ _WIDGET_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$")
24
+ # Same shapes as the server: the opener starts a line; the first closer ends the payload.
25
+ _OPENER_RE = re.compile(r"""<iframe(?=[\s>])((?:[^"'<>]|"[^"]*"|'[^']*')*)>""", re.IGNORECASE)
26
+ _CLOSER_RE = re.compile(r"</iframe\s*>", re.IGNORECASE)
27
+ _CLOSER_PREFIX_RE = re.compile(r"</iframe", re.IGNORECASE)
28
+ _OPENER_PREFIX_RE = re.compile(r"<iframe", re.IGNORECASE)
29
+ _OPENING_FENCE_RE = re.compile(r"( {0,3})(`{3,}|~{3,})(.*)")
30
+ _CLOSING_FENCE_RE = re.compile(r" {0,3}(`+|~+)[ \t]*")
31
+ _ATTRIBUTE_RE = re.compile(r"""([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?""")
32
+
33
+
34
+ class WidgetSpan(NamedTuple):
35
+ """A complete widget block from its opener line start to the end of its closing line."""
36
+ start: int
37
+ end: int
38
+ title: str
39
+
40
+
41
+ class WidgetScan(NamedTuple):
42
+ widgets: List[WidgetSpan]
43
+ # Offset of an opener whose closing tag has not arrived (a streaming preview), else None.
44
+ unclosed_at: Optional[int]
45
+
46
+
47
+ class WidgetError(ValueError):
48
+ """A widget problem the model can fix."""
49
+
50
+
51
+ def utf16_len(text: str) -> int:
52
+ return len(text.encode("utf-16-le")) // 2
53
+
54
+
55
+ def _read_line(text: str, start: int) -> tuple[int, int]:
56
+ """(content_end, next_line_start) for the line starting at ``start``."""
57
+ newline = text.find("\n", start)
58
+ raw_end = newline if newline >= 0 else len(text)
59
+ content_end = raw_end - 1 if raw_end > start and text[raw_end - 1] == "\r" else raw_end
60
+ return content_end, (raw_end + 1 if raw_end < len(text) else len(text))
61
+
62
+
63
+ def _opener_title(attributes: str) -> str:
64
+ for match in _ATTRIBUTE_RE.finditer(attributes):
65
+ if match.group(1).lower() != "title":
66
+ continue
67
+ value = next((group for group in match.groups()[1:] if group is not None), "")
68
+ return " ".join(_html.unescape(value).split())[:MAX_WIDGET_TITLE_CHARS]
69
+ return ""
70
+
71
+
72
+ def scan_widgets(text: str) -> WidgetScan:
73
+ """Find widget blocks as the server does: outside code fences, with the opener at a line start."""
74
+ widgets: List[WidgetSpan] = []
75
+ fence: Optional[tuple[str, int]] = None
76
+ cursor = 0
77
+ while cursor < len(text):
78
+ content_end, next_line = _read_line(text, cursor)
79
+ line = text[cursor:content_end]
80
+ if fence:
81
+ closing = _CLOSING_FENCE_RE.fullmatch(line)
82
+ if closing and closing.group(1)[0] == fence[0] and len(closing.group(1)) >= fence[1]:
83
+ fence = None
84
+ cursor = next_line
85
+ continue
86
+ opening = _OPENING_FENCE_RE.fullmatch(line)
87
+ if opening and not (opening.group(2)[0] == "`" and "`" in opening.group(3)):
88
+ fence = (opening.group(2)[0], len(opening.group(2)))
89
+ cursor = next_line
90
+ continue
91
+ opener = _OPENER_RE.match(line)
92
+ if not opener:
93
+ cursor = next_line
94
+ continue
95
+ closer = _CLOSER_RE.search(text, cursor + opener.end())
96
+ if not closer:
97
+ return WidgetScan(widgets, cursor)
98
+ closing_start = text.rfind("\n", 0, closer.start()) + 1
99
+ closing_end, after_closing = _read_line(text, closing_start)
100
+ widgets.append(WidgetSpan(cursor, closing_end, _opener_title(opener.group(1) or "")))
101
+ cursor = after_closing
102
+ return WidgetScan(widgets, None)
103
+
104
+
105
+ def has_widget_markup(text: str) -> bool:
106
+ """Cheap pre-check before a full scan."""
107
+ return _OPENER_PREFIX_RE.search(text) is not None
108
+
109
+
110
+ def split_widget_aware(
111
+ text: str,
112
+ max_length: int,
113
+ split_plain: Callable[[str, int], List[str]],
114
+ len_fn: Callable[[str], int] = len,
115
+ ) -> Optional[List[str]]:
116
+ """Split ``text`` into messages without cutting a widget or putting two in one message.
117
+
118
+ Returns None when the text has no complete widget, so the caller keeps its plain splitter.
119
+ A widget block longer than ``max_length`` becomes its own message (the server allows
120
+ 100,000 UTF-16 units); prose around widgets is split with ``split_plain``.
121
+ """
122
+ if not has_widget_markup(text):
123
+ return None
124
+ scan = scan_widgets(text)
125
+ if not scan.widgets:
126
+ return None
127
+ if len(scan.widgets) == 1 and len_fn(text) <= max_length:
128
+ return [text]
129
+
130
+ segments: List[tuple[bool, str]] = []
131
+ cursor = 0
132
+ for widget in scan.widgets:
133
+ segments.append((False, text[cursor:widget.start]))
134
+ segments.append((True, text[widget.start:widget.end]))
135
+ cursor = widget.end
136
+ segments.append((False, text[cursor:]))
137
+
138
+ chunks: List[str] = []
139
+ current = ""
140
+ current_has_widget = False
141
+
142
+ def joined(part: str) -> str:
143
+ return f"{current}\n\n{part}" if current else part
144
+
145
+ def flush() -> None:
146
+ nonlocal current, current_has_widget
147
+ if current.strip():
148
+ chunks.append(current)
149
+ current = ""
150
+ current_has_widget = False
151
+
152
+ for is_widget, raw in segments:
153
+ part = raw.strip("\r\n")
154
+ if not part.strip():
155
+ continue
156
+ if is_widget:
157
+ if current and (current_has_widget or len_fn(joined(part)) > max_length):
158
+ flush()
159
+ current = joined(part)
160
+ current_has_widget = True
161
+ continue
162
+ if len_fn(joined(part)) <= max_length:
163
+ current = joined(part)
164
+ continue
165
+ flush()
166
+ pieces = [piece for piece in split_plain(part, max_length) if piece.strip()]
167
+ chunks.extend(pieces[:-1])
168
+ current = pieces[-1] if pieces else ""
169
+ flush()
170
+ return chunks or [text]
171
+
172
+
173
+ def streaming_preview(text: str) -> str:
174
+ """Text safe to show while a reply streams: hide a widget whose closing tag hasn't arrived."""
175
+ if not has_widget_markup(text):
176
+ return text
177
+ scan = scan_widgets(text)
178
+ if scan.unclosed_at is None:
179
+ return text
180
+ return text[:scan.unclosed_at].rstrip() or "…"
181
+
182
+
183
+ def replace_widgets(text: str, replacement: Callable[[WidgetSpan], str]) -> str:
184
+ if not has_widget_markup(text):
185
+ return text
186
+ scan = scan_widgets(text)
187
+ out: List[str] = []
188
+ cursor = 0
189
+ for widget in scan.widgets:
190
+ out.append(text[cursor:widget.start])
191
+ out.append(replacement(widget))
192
+ cursor = widget.end
193
+ out.append(text[cursor:])
194
+ return "".join(out)
195
+
196
+
197
+ def without_widgets(text: str) -> str:
198
+ """Replace each widget with a short notice, for a fallback after the server rejected one."""
199
+ def notice(widget: WidgetSpan) -> str:
200
+ return f'[Widget "{widget.title or "HTML preview"}" could not be displayed]'
201
+
202
+ replaced = replace_widgets(text, notice)
203
+ scan = scan_widgets(replaced)
204
+ if scan.unclosed_at is not None:
205
+ replaced = replaced[:scan.unclosed_at].rstrip() + "\n\n[Widget could not be displayed]"
206
+ return replaced
207
+
208
+
209
+ def _escape_attribute(value: str) -> str:
210
+ # The server decodes exactly &quot; &amp; &lt; &gt; &#39; &apos; in attribute values.
211
+ return value.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
212
+
213
+
214
+ def _height(value: Any) -> Optional[int]:
215
+ if value is None or value == "":
216
+ return None
217
+ if isinstance(value, bool):
218
+ raise WidgetError("height must be a whole number of CSS pixels from 80 to 2000")
219
+ text = str(value).strip().lower().removesuffix("px")
220
+ if not text.isdigit():
221
+ raise WidgetError("height must be a whole number of CSS pixels from 80 to 2000")
222
+ height = int(text)
223
+ if height < MIN_WIDGET_HEIGHT or height > MAX_WIDGET_HEIGHT:
224
+ raise WidgetError("height must be from 80 to 2000 CSS pixels")
225
+ return height
226
+
227
+
228
+ def build_widget_markdown(
229
+ *,
230
+ html: str,
231
+ title: Optional[str] = None,
232
+ widget_id: Optional[str] = None,
233
+ height: Any = None,
234
+ text: Optional[str] = None,
235
+ ) -> str:
236
+ """Markdown for one widget message: optional lead text, then the ``<iframe>`` block."""
237
+ body = str(html or "").strip("\r\n")
238
+ if not body.strip():
239
+ raise WidgetError("html is required")
240
+ if _CLOSER_PREFIX_RE.search(body):
241
+ raise WidgetError("html must not contain </iframe: it would end the widget early (nested iframes are not allowed)")
242
+ if len(body.encode("utf-8")) > MAX_WIDGET_HTML_BYTES:
243
+ raise WidgetError("html exceeds 256 KiB")
244
+ attributes: List[str] = []
245
+ clean_title = " ".join(str(title or "").split())
246
+ if len(clean_title) > MAX_WIDGET_TITLE_CHARS:
247
+ raise WidgetError(f"title is longer than {MAX_WIDGET_TITLE_CHARS} characters")
248
+ if clean_title:
249
+ attributes.append(f'title="{_escape_attribute(clean_title)}"')
250
+ clean_id = str(widget_id or "").strip()
251
+ if clean_id:
252
+ if not _WIDGET_ID_RE.fullmatch(clean_id):
253
+ raise WidgetError("widget_id must be 1-64 characters of letters, digits, _ . : -")
254
+ attributes.append(f'id="{clean_id}"')
255
+ parsed_height = _height(height)
256
+ if parsed_height is not None:
257
+ attributes.append(f'height="{parsed_height}"')
258
+ opener = "<iframe" + "".join(f" {attribute}" for attribute in attributes) + ">"
259
+ block = f"{opener}\n{body}\n</iframe>"
260
+ lead = str(text or "").strip()
261
+ markdown = f"{lead}\n\n{block}" if lead else block
262
+ scan = scan_widgets(markdown)
263
+ if len(scan.widgets) != 1 or scan.unclosed_at is not None or scan.widgets[0].end != len(markdown):
264
+ raise WidgetError("text must not contain another <iframe> or an unclosed code fence; a message holds one widget")
265
+ if utf16_len(markdown) > MAX_MESSAGE_TEXT_UTF16_UNITS or len(markdown.encode("utf-8")) > MAX_MESSAGE_TEXT_UTF8_BYTES:
266
+ raise WidgetError("message exceeds Inline's 100,000 character limit")
267
+ return markdown