diff --git a/application/agents/tools/artifact_generator.py b/application/agents/tools/artifact_generator.py
index 18f5dc18..31203402 100644
--- a/application/agents/tools/artifact_generator.py
+++ b/application/agents/tools/artifact_generator.py
@@ -1,7 +1,7 @@
"""Artifact Generator tool: render editable documents from a JSON spec and version them append-only.
The ``artifact_versions.spec`` JSONB is the source of truth; the rendered
-``.pptx``/``.docx``/``.xlsx``/``.pdf`` is derived. ``create_artifact`` stores
+``.pptx``/``.docx``/``.xlsx``/``.pdf``/``.html`` is derived. ``create_artifact`` stores
v1, ``edit_artifact`` applies an RFC 7386 merge-patch to the current spec and
appends a version, ``rewrite_artifact`` replaces the spec wholesale and appends
a version. Rendering runs a FIXED program in the sandbox that reads the spec as
@@ -58,6 +58,11 @@ _KIND_INFO: Dict[str, Dict[str, str]] = {
"ext": "pdf",
"mime": "application/pdf",
},
+ "html": {
+ "kind": "html",
+ "ext": "html",
+ "mime": "text/html",
+ },
}
# Tight per-kind JSON schemas. ``additionalProperties: false`` keeps specs minimal
@@ -144,6 +149,72 @@ _SCHEMAS: Dict[str, Dict[str, Any]] = {
},
"required": ["blocks"],
},
+ "html": {
+ "type": "object",
+ "additionalProperties": False,
+ "properties": {
+ "title": {"type": "string"},
+ "blocks": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "additionalProperties": False,
+ "properties": {
+ "type": {"const": "heading"},
+ "text": {"type": "string"},
+ "level": {"type": "integer", "minimum": 1, "maximum": 3},
+ },
+ "required": ["type", "text"],
+ },
+ {
+ "type": "object",
+ "additionalProperties": False,
+ "properties": {
+ "type": {"const": "paragraph"},
+ "text": {"type": "string"},
+ },
+ "required": ["type", "text"],
+ },
+ {
+ "type": "object",
+ "additionalProperties": False,
+ "properties": {
+ "type": {"const": "list"},
+ "ordered": {"type": "boolean"},
+ "items": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["type", "items"],
+ },
+ {
+ "type": "object",
+ "additionalProperties": False,
+ "properties": {
+ "type": {"const": "table"},
+ "headers": {"type": "array", "items": {"type": "string"}},
+ "rows": {
+ "type": "array",
+ "items": {"type": "array", "items": {"type": "string"}},
+ },
+ },
+ "required": ["type", "rows"],
+ },
+ {
+ "type": "object",
+ "additionalProperties": False,
+ "properties": {
+ "type": {"const": "code"},
+ "text": {"type": "string"},
+ },
+ "required": ["type", "text"],
+ },
+ ],
+ },
+ },
+ },
+ "required": ["blocks"],
+ },
}
# FIXED renderer programs. Each reads ``spec.json`` from the workspace as DATA
@@ -227,6 +298,66 @@ _RENDERERS: Dict[str, str] = {
" story.append(Spacer(1, 6))\n"
"SimpleDocTemplate({out_path!r}, pagesize=letter).build(story)\n"
),
+ "html": (
+ "import json\n"
+ "import html\n"
+ "spec = json.load(open({spec_path!r}))\n"
+ "def esc(value):\n"
+ " return html.escape('' if value is None else str(value))\n"
+ "parts = []\n"
+ "title = spec.get('title')\n"
+ "if title:\n"
+ " parts.append('
' + esc(title) + '
')\n"
+ "for block in spec.get('blocks', []):\n"
+ " kind = block.get('type')\n"
+ " if kind == 'heading':\n"
+ " level = block.get('level') or 2\n"
+ " try:\n"
+ " level = int(level)\n"
+ " except (TypeError, ValueError):\n"
+ " level = 2\n"
+ " level = min(max(level, 1), 3) + 1\n"
+ " parts.append('%s' % (level, esc(block.get('text', '')), level))\n"
+ " elif kind == 'paragraph':\n"
+ " parts.append('' + esc(block.get('text', '')) + '
')\n"
+ " elif kind == 'list':\n"
+ " tag = 'ol' if block.get('ordered') else 'ul'\n"
+ " items = ''.join('' + esc(i) + '' for i in (block.get('items') or []))\n"
+ " parts.append('<%s>%s%s>' % (tag, items, tag))\n"
+ " elif kind == 'table':\n"
+ " rows_html = []\n"
+ " headers = block.get('headers')\n"
+ " if headers:\n"
+ " cells = ''.join('' + esc(h) + ' | ' for h in headers)\n"
+ " rows_html.append('' + cells + '
')\n"
+ " body = []\n"
+ " for row in (block.get('rows') or []):\n"
+ " cells = ''.join('' + esc(c) + ' | ' for c in row)\n"
+ " body.append('' + cells + '
')\n"
+ " rows_html.append('' + ''.join(body) + '')\n"
+ " parts.append('' + ''.join(rows_html) + '
')\n"
+ " elif kind == 'code':\n"
+ " parts.append('' + esc(block.get('text', '')) + '
')\n"
+ # CSS braces are doubled so the outer ``_RENDERERS[kind].format(...)`` leaves them literal.
+ "css = (\n"
+ " 'body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;'\n"
+ " 'line-height:1.6;color:#1a1a1a;max-width:800px;margin:0 auto;padding:24px}}'\n"
+ " 'h1,h2,h3,h4{{line-height:1.25;margin:1.2em 0 0.5em}}'\n"
+ " 'table{{border-collapse:collapse;width:100%;margin:1em 0}}'\n"
+ " 'th,td{{border:1px solid #d0d0d0;padding:6px 10px;text-align:left}}'\n"
+ " 'th{{background:#f5f5f5}}'\n"
+ " 'pre{{background:#f5f5f5;padding:12px;border-radius:6px;overflow:auto}}'\n"
+ " 'code{{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}}'\n"
+ ")\n"
+ "doc = (\n"
+ " ''\n"
+ " ''\n"
+ " '' + esc(title or 'Report') + ''\n"
+ " ''\n"
+ " + ''.join(parts) + ''\n"
+ ")\n"
+ "open({out_path!r}, 'w', encoding='utf-8').write(doc)\n"
+ ),
}
@@ -247,7 +378,7 @@ def merge_patch(target: Any, patch: Any) -> Any:
class ArtifactGeneratorTool(Tool):
"""Artifact Generator
- Render editable documents (presentation/document/spreadsheet/pdf) from a JSON spec and version them.
+ Render editable documents (presentation/document/spreadsheet/pdf/html) from a JSON spec and version them.
"""
def __init__(self, tool_config: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None) -> None:
@@ -277,7 +408,13 @@ class ArtifactGeneratorTool(Tool):
"parameters": {
"type": "object",
"properties": {
- "kind": {"type": "string", "enum": kinds, "description": "Document kind to render."},
+ "kind": {
+ "type": "string",
+ "enum": kinds,
+ "description": (
+ "Document kind to render; `html` is an inline-rendered, versionable HTML report."
+ ),
+ },
"title": {"type": "string", "description": "Optional artifact title."},
"spec": {"type": "object", "description": "Document spec matching the kind's schema."},
},
@@ -373,7 +510,7 @@ class ArtifactGeneratorTool(Tool):
return {"status": "error", "error": rendered["error"]}
info = _KIND_INFO[kind]
- filename = self._filename(title, info["ext"])
+ filename = self._filename(title, kind)
try:
ref = persist_new_artifact(
user_id=self.user_id,
@@ -422,7 +559,7 @@ class ArtifactGeneratorTool(Tool):
if rendered.get("error"):
return {"status": "error", "error": rendered["error"]}
info = _KIND_INFO[kind]
- filename = self._filename(None, info["ext"])
+ filename = self._filename(None, kind)
try:
ref = append_artifact_version(
user_id=self.user_id,
@@ -565,10 +702,12 @@ class ArtifactGeneratorTool(Tool):
}
@staticmethod
- def _filename(title: Optional[str], ext: str) -> str:
+ def _filename(title: Optional[str], kind: str) -> str:
"""Derive a download filename from a title (or a generic stem) plus the kind extension."""
+ if kind == "html":
+ return "report.html"
stem = (title or "artifact").strip() or "artifact"
- return f"{stem}.{ext}"
+ return f"{stem}.{_KIND_INFO[kind]['ext']}"
def _resolve_session_id(self) -> Optional[str]:
"""Derive the sandbox session id from the bound conversation/run; sanitize to the gateway charset."""
diff --git a/tests/agents/tools/test_artifact_generator_unit.py b/tests/agents/tools/test_artifact_generator_unit.py
index 5bcd6c00..116f5c1c 100644
--- a/tests/agents/tools/test_artifact_generator_unit.py
+++ b/tests/agents/tools/test_artifact_generator_unit.py
@@ -158,3 +158,84 @@ def test_pdf_renderer_escapes_markup_and_does_not_execute():
spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]}
out_path = _render_in_process("pdf", spec)
assert os.path.getsize(out_path) > 0
+
+
+# ---------------------------------------------------------------------------
+# html kind: schema, renderer injection safety, output escaping
+# ---------------------------------------------------------------------------
+
+
+def test_validate_accepts_html_spec():
+ spec = {
+ "title": "Report",
+ "blocks": [
+ {"type": "heading", "text": "Intro", "level": 2},
+ {"type": "paragraph", "text": "Body."},
+ {"type": "list", "ordered": True, "items": ["a", "b"]},
+ {"type": "table", "headers": ["h"], "rows": [["c"]]},
+ {"type": "code", "text": "print(1)"},
+ ],
+ }
+ assert _tool()._validate("html", spec) is None
+
+
+def test_validate_rejects_html_unknown_block_key():
+ err = _tool()._validate("html", {"blocks": [{"type": "paragraph", "text": "x", "bogus": 1}]})
+ assert err["status"] == "error"
+
+
+def test_validate_rejects_html_bad_heading_level():
+ err = _tool()._validate("html", {"blocks": [{"type": "heading", "text": "x", "level": 9}]})
+ assert err["status"] == "error"
+
+
+def test_html_renderer_does_not_execute_spec_code(capfd, tmp_path):
+ # The renderer json.loads the spec as data; a value that is Python source
+ # must never run. If the program string-interpolated the spec it would.
+ sentinel = tmp_path / "pwned.txt"
+ payload = (
+ f"'''__import__('os').system('echo PWNED > {sentinel}')'''; "
+ "print('SHOULD_NOT_PRINT')"
+ )
+ spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]}
+ out_path = _render_in_process("html", spec)
+
+ captured = capfd.readouterr()
+ assert "SHOULD_NOT_PRINT" not in captured.out
+ assert "PWNED" not in captured.out
+ assert not sentinel.exists()
+ assert os.path.getsize(out_path) > 0
+
+
+def test_html_renderer_escapes_spec_markup():
+ # Spec text carrying live markup must appear HTML-ESCAPED in the output, so
+ # no raw "
+ img = '
'
+ spec = {
+ "title": script,
+ "blocks": [
+ {"type": "paragraph", "text": img},
+ {"type": "heading", "text": "h", "level": 1},
+ {"type": "list", "items": ["x"]},
+ {"type": "table", "headers": ["th"], "rows": [["td"]]},
+ {"type": "code", "text": ""},
+ ],
+ }
+ with open(_render_in_process("html", spec), encoding="utf-8") as handle:
+ html_doc = handle.read()
+
+ # No live tag from spec content survives.
+ assert "" not in html_doc
+ assert "
h" not in html_doc
+ assert "x" not in html_doc
+ assert "th" not in html_doc
+ assert "td" not in html_doc
+ # The escaped forms are present instead.
+ assert "<script>alert(1)</script>" in html_doc
+ assert "<img src=x onerror=" in html_doc
+ # The fixed structural HTML the renderer emits is intact.
+ assert "" in html_doc
+ assert "" in html_doc and "
" in html_doc
+ assert "" in html_doc and "" in html_doc
diff --git a/tests/integration/test_artifact_generator_e2e.py b/tests/integration/test_artifact_generator_e2e.py
index 3bcc12bc..25987d8c 100644
--- a/tests/integration/test_artifact_generator_e2e.py
+++ b/tests/integration/test_artifact_generator_e2e.py
@@ -211,6 +211,65 @@ def test_create_spreadsheet_xlsx(wired_tool):
assert ws.cell(row=2, column=2).value == 2
+def test_create_edit_html_versions(wired_tool):
+ tool, conversation_id, pg_engine, storage = wired_tool
+
+ # create -> v1: an html report rendered to report.html (kind html, text/html).
+ spec = {
+ "title": "Findings ",
+ "blocks": [
+ {"type": "heading", "text": "Overview", "level": 2},
+ {"type": "paragraph", "text": "Body ."},
+ {"type": "list", "ordered": False, "items": ["one", "two"]},
+ {"type": "table", "headers": ["k", "v"], "rows": [["a", "1"]]},
+ ],
+ }
+ created = tool.execute_action("create_artifact", kind="html", title="Report", spec=spec)
+ assert created["status"] == "ok", created
+ assert created["version"] == 1
+ assert created["mime_type"] == "text/html"
+ assert created["filename"] == "report.html"
+ assert created["ref"] == "A1"
+ artifact_id = created["artifact_id"]
+
+ with pg_engine.connect() as conn:
+ repo = ArtifactsRepository(conn)
+ artifact = repo.get_artifact_in_parent(artifact_id, conversation_id=conversation_id)
+ v1 = repo.get_version(artifact_id, 1)
+ assert artifact["kind"] == "html"
+ assert artifact["current_version"] == 1
+ assert v1["spec"] == spec
+ assert v1["produced_by"]["spec_kind"] == "html"
+
+ # The stored bytes are a valid HTML doc with escaped title/blocks (no live tag).
+ html_doc = storage.get_file(v1["storage_path"]).read().decode("utf-8")
+ assert html_doc.startswith("")
+ assert "Findings <hi>" in html_doc
+ assert "<script>alert(1)</script>" in html_doc
+ assert "" not in html_doc
+ assert "one" in html_doc and "| a | " in html_doc
+
+ # edit (merge-patch) -> v2: replace the title, keep v1 intact.
+ edited = tool.execute_action("edit_artifact", id="A1", spec_patch={"title": "Updated"})
+ assert edited["status"] == "ok", edited
+ assert edited["version"] == 2
+ assert edited["artifact_id"] == artifact_id
+ assert edited["ref"] == "A1"
+
+ with pg_engine.connect() as conn:
+ repo = ArtifactsRepository(conn)
+ artifact = repo.get_artifact_in_parent(artifact_id, conversation_id=conversation_id)
+ v1_again = repo.get_version(artifact_id, 1)
+ v2 = repo.get_version(artifact_id, 2)
+ assert artifact["current_version"] == 2
+ # Append-only: v1 unchanged; v2 carries the merged spec re-rendered.
+ assert v1_again["spec"] == spec
+ assert v2["spec"]["title"] == "Updated"
+ assert len(v2["spec"]["blocks"]) == 4
+ v2_doc = storage.get_file(v2["storage_path"]).read().decode("utf-8")
+ assert "Updated
" in v2_doc
+
+
def test_invalid_spec_creates_no_artifact(wired_tool):
tool, conversation_id, pg_engine, _storage = wired_tool