From c089a5ec2a3804e603493a56f25480bf647b8570 Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 24 Aug 2026 04:35:12 -0300 Subject: [PATCH] hermes(runtime): emit artifacts and web sources from real tool output HUX-04/HUX-08: after a successful tool execution the hux-runtime plugin now registers freshly written files as typed artifacts (create or immutable version by conversation-scoped title, bounded to 1 MiB, deterministic idempotency keys) and records up to three deduplicated web sources from real network/web_search output. Emitters are fail-open and can never break the tool result. Delivered through the plugin ConfigMap; 95% branch coverage; quality contract updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf --- services/hermes/chat-statefulset.yaml | 1 + services/hermes/kustomization.yaml | 1 + services/hermes/plugins/hux-runtime/NOTES.md | 12 ++ .../hermes/plugins/hux-runtime/emitters.py | 142 ++++++++++++++++++ .../hermes/plugins/hux-runtime/runtime.py | 8 +- testing/quality_contract.json | 4 + .../tests/test_hermes_hux_runtime_emitters.py | 140 +++++++++++++++++ 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 services/hermes/plugins/hux-runtime/emitters.py create mode 100644 testing/tests/test_hermes_hux_runtime_emitters.py diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml index c99e84ef..0eedd3d2 100644 --- a/services/hermes/chat-statefulset.yaml +++ b/services/hermes/chat-statefulset.yaml @@ -671,6 +671,7 @@ spec: items: - {key: __init__.py, path: __init__.py} - {key: context_ids.py, path: context_ids.py} + - {key: emitters.py, path: emitters.py} - {key: runtime.py, path: runtime.py} - {key: tool_policy.py, path: tool_policy.py} - {key: plugin.yaml, path: plugin.yaml} diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index 152b0e33..fbadda29 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -200,6 +200,7 @@ configMapGenerator: files: - __init__.py=plugins/hux-runtime/__init__.py - context_ids.py=plugins/hux-runtime/context_ids.py + - emitters.py=plugins/hux-runtime/emitters.py - runtime.py=plugins/hux-runtime/runtime.py - tool_policy.py=plugins/hux-runtime/tool_policy.py - plugin.yaml=plugins/hux-runtime/plugin.yaml diff --git a/services/hermes/plugins/hux-runtime/NOTES.md b/services/hermes/plugins/hux-runtime/NOTES.md index d2128d43..717ff494 100644 --- a/services/hermes/plugins/hux-runtime/NOTES.md +++ b/services/hermes/plugins/hux-runtime/NOTES.md @@ -35,3 +35,15 @@ Quality registration must include `services/hermes/plugins/hux-runtime/*.py` as source and `testing/tests/test_hermes_hux_runtime_plugin.py` as its focused test. The current focused gate is 54 passing tests with every production file above 95% line and branch coverage. + +## Post-tool emitters (HUX-04 / HUX-08) + +`emitters.sync_after_tool` runs only after a successful tool execution and +never raises into the tool path. A successful `write_files` tool whose +arguments name a real, non-symlink file of at most 1 MiB creates (or, when a +same-titled artifact already exists in the conversation, appends an immutable +version to) a typed artifact through the loopback API; the service's secret +scrubbing and `restricted` forcing still apply. A successful `network` or +`web_search` tool records up to three deduplicated `web` sources found in the +first 64 KiB of real tool output. Both use deterministic idempotency keys, so +retries never duplicate records. diff --git a/services/hermes/plugins/hux-runtime/emitters.py b/services/hermes/plugins/hux-runtime/emitters.py new file mode 100644 index 00000000..dbf09dc8 --- /dev/null +++ b/services/hermes/plugins/hux-runtime/emitters.py @@ -0,0 +1,142 @@ +"""Fail-open post-tool emitters: artifacts from real writes, sources from real web output. + +HUX-04 requires agent-created files to appear in the artifact workspace +automatically; HUX-08 requires real web tool output to leave source +records behind. Both emitters run strictly after a successful tool +execution, never raise into the tool path, never send secrets beyond the +written content itself (the service applies its own secret scrubbing and +forces ``restricted`` sensitivity on hits), and stay bounded. +""" + +from __future__ import annotations + +import base64 +import hashlib +import re +from pathlib import Path +from typing import Any + +MAX_ARTIFACT_BYTES = 1024 * 1024 +MAX_RESULT_SCAN_BYTES = 64 * 1024 +MAX_SOURCES_PER_CALL = 3 +PATH_KEYS = ("path", "file_path", "filename", "target_path", "output_path") +URL = re.compile(r"""https?://[^\s"'<>)\]]{8,500}""") +TYPE_BY_SUFFIX = { + ".md": "markdown", ".markdown": "markdown", ".html": "html", ".htm": "html", + ".svg": "svg", ".json": "json", ".csv": "csv", ".png": "image", ".jpg": "image", + ".jpeg": "image", ".gif": "image", ".webp": "image", ".pdf": "document", + ".txt": "document", ".rst": "document", ".wav": "audio", ".mp3": "audio", +} +MIME_BY_TYPE = { + "markdown": "text/markdown", "code": "text/plain", "html": "text/html", + "svg": "image/svg+xml", "image": "application/octet-stream", "json": "application/json", + "csv": "text/csv", "document": "text/plain", "audio": "application/octet-stream", +} + + +def _written_path(args: Any) -> Path | None: + """The one plausible filesystem target of a write tool, or None.""" + if not isinstance(args, dict): + return None + for key in PATH_KEYS: + value = args.get(key) + if isinstance(value, str) and value and len(value) < 4096: + path = Path(value) + try: + if path.is_file() and not path.is_symlink(): + return path + except OSError: + return None + return None + + +def _artifact_body(path: Path, data: bytes, conversation_id: str, project_id: str) -> dict[str, Any]: + artifact_type = TYPE_BY_SUFFIX.get(path.suffix.lower(), "code") + body: dict[str, Any] = { + "type": artifact_type, + "title": path.name[-200:], + "mime": MIME_BY_TYPE[artifact_type], + "conversation_id": conversation_id, + "project_id": project_id, + } + try: + body["content"] = data.decode("utf-8", errors="strict") + except UnicodeDecodeError: + body["content_base64"] = base64.b64encode(data).decode("ascii") + return body + + +def record_artifact(client: Any, call: Any, args: Any) -> str | None: + """Create or version one artifact for a freshly written file; never raises.""" + try: + path = _written_path(args) + if path is None or path.stat().st_size > MAX_ARTIFACT_BYTES: + return None + data = path.read_bytes() + digest = hashlib.sha256(data).hexdigest()[:32] + body = _artifact_body(path, data, call.conversation_id, call.project_id) + listing = client.get("/hux/v1/artifacts", {"conversation_id": call.conversation_id, "cursor": "0"}) + items = listing.body.get("items", []) if isinstance(listing.body, dict) else [] + existing = next( + (item for item in items + if isinstance(item, dict) and item.get("title") == body["title"]), + None, + ) + if existing is None: + created = client.post( + "/hux/v1/artifacts", body, + idempotency_key=f"artifact-sync:new:{digest}"[:120], + ) + return created.body.get("id") if isinstance(created.body, dict) else None + version = {key: body[key] for key in ("mime",) if key in body} + version.update({k: v for k, v in body.items() if k.startswith("content")}) + version["diff_from"] = existing.get("current_version") + client.request( + "POST", f"/hux/v1/artifacts/{existing['id']}/versions", version, + idempotency_key=f"artifact-sync:{existing['id']}:{digest}"[:120], + if_match=existing.get("revision"), + ) + return existing.get("id") + except Exception: + return None + + +def record_sources(client: Any, result: Any) -> int: + """Record up to three deduplicated web sources found in real tool output.""" + try: + if isinstance(result, bytes): + text = result[:MAX_RESULT_SCAN_BYTES].decode("utf-8", errors="replace") + elif isinstance(result, str): + text = result[:MAX_RESULT_SCAN_BYTES] + else: + return 0 + recorded = 0 + seen: set[str] = set() + for match in URL.finditer(text): + url = match.group(0).rstrip(".,;") + if url in seen: + continue + seen.add(url) + digest = hashlib.sha256(url.encode()).hexdigest()[:32] + client.post( + "/hux/v1/sources", + {"kind": "web", "title": url[:300], "uri": url[:2000], "classification": "unknown"}, + idempotency_key=f"source-sync:{digest}"[:120], + ) + recorded += 1 + if recorded >= MAX_SOURCES_PER_CALL: + break + return recorded + except Exception: + return 0 + + +def sync_after_tool(client: Any, call: Any, capability: str, args: Any, result: Any) -> None: + """Route one successful tool execution to its emitters; always returns.""" + try: + if capability == "write_files": + record_artifact(client, call, args) + elif capability in {"network", "web_search"}: + record_sources(client, result) + except Exception: + return diff --git a/services/hermes/plugins/hux-runtime/runtime.py b/services/hermes/plugins/hux-runtime/runtime.py index 237ac2fd..67f008a7 100644 --- a/services/hermes/plugins/hux-runtime/runtime.py +++ b/services/hermes/plugins/hux-runtime/runtime.py @@ -19,6 +19,7 @@ from .hux_hook import ( ) from .context_ids import ContextIds, ContextUnavailable +from .emitters import sync_after_tool from .tool_policy import classify BLOCK_SCHEMA = "hux.tool_block.v1" @@ -228,7 +229,12 @@ class Runtime: except BaseException: self._after(call, tool_name, argument_hash, False, 0, started) raise - self._after(call, tool_name, argument_hash, _result_ok(result), _result_size(result), started) + ok = _result_ok(result) + self._after(call, tool_name, argument_hash, ok, _result_size(result), started) + if ok: + # HUX-04/08: real written files and real web output leave records + # behind. Emitters are bounded and can never break the tool result. + sync_after_tool(self.client, call, policy.capability, args, result) return result def _after( diff --git a/testing/quality_contract.json b/testing/quality_contract.json index 82fc927f..dd21ec2e 100644 --- a/testing/quality_contract.json +++ b/testing/quality_contract.json @@ -137,6 +137,7 @@ "services/hermes/plugins/auto-router/hux_mode.py", "services/hermes/plugins/hux-runtime/__init__.py", "services/hermes/plugins/hux-runtime/context_ids.py", + "services/hermes/plugins/hux-runtime/emitters.py", "services/hermes/plugins/hux-runtime/runtime.py", "services/hermes/plugins/hux-runtime/tool_policy.py" ], @@ -246,6 +247,7 @@ "services/hermes/plugins/auto-router/hux_mode.py", "services/hermes/plugins/hux-runtime/__init__.py", "services/hermes/plugins/hux-runtime/context_ids.py", + "services/hermes/plugins/hux-runtime/emitters.py", "services/hermes/plugins/hux-runtime/runtime.py", "services/hermes/plugins/hux-runtime/tool_policy.py" ], @@ -446,6 +448,7 @@ "services/hermes/plugins/auto-router/hux_mode.py", "services/hermes/plugins/hux-runtime/__init__.py", "services/hermes/plugins/hux-runtime/context_ids.py", + "services/hermes/plugins/hux-runtime/emitters.py", "services/hermes/plugins/hux-runtime/runtime.py", "services/hermes/plugins/hux-runtime/tool_policy.py" ], @@ -565,6 +568,7 @@ "services/hermes/plugins/auto-router/hux_mode.py", "services/hermes/plugins/hux-runtime/__init__.py", "services/hermes/plugins/hux-runtime/context_ids.py", + "services/hermes/plugins/hux-runtime/emitters.py", "services/hermes/plugins/hux-runtime/runtime.py", "services/hermes/plugins/hux-runtime/tool_policy.py" ] diff --git a/testing/tests/test_hermes_hux_runtime_emitters.py b/testing/tests/test_hermes_hux_runtime_emitters.py new file mode 100644 index 00000000..d835ec6c --- /dev/null +++ b/testing/tests/test_hermes_hux_runtime_emitters.py @@ -0,0 +1,140 @@ +"""HUX-04/08 post-tool emitters: real files and real web output leave records.""" + +from __future__ import annotations + +import base64 +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[2] +PLUGIN = ROOT / "services" / "hermes" / "plugins" / "hux-runtime" + +SPEC = importlib.util.spec_from_file_location("hermes_hux_emitters", PLUGIN / "emitters.py") +assert SPEC and SPEC.loader +emitters = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = emitters +SPEC.loader.exec_module(emitters) + +CALL = SimpleNamespace(conversation_id="conv_" + "c" * 32, project_id="prj_" + "a" * 32) + + +class Client: + """Capture every emitter call; scriptable listing and failures.""" + + def __init__(self, items=None, fail=None): + self.items = items or [] + self.fail = fail + self.calls = [] + + def get(self, path, query=None): + self.calls.append(("GET", path, query, None)) + if self.fail == "get": + raise OSError("down") + return SimpleNamespace(body={"items": self.items}) + + def post(self, path, body, idempotency_key=None): + self.calls.append(("POST", path, body, idempotency_key)) + if self.fail == "post": + raise OSError("down") + return SimpleNamespace(body={"id": "art_new0001"}) + + def request(self, method, path, body=None, *, idempotency_key=None, if_match=None): + self.calls.append((method, path, body, idempotency_key, if_match)) + if self.fail == "request": + raise OSError("down") + return SimpleNamespace(body={}) + + +def test_new_text_file_creates_a_typed_artifact(tmp_path): + target = tmp_path / "report.md" + target.write_text("# hello\n") + client = Client() + assert emitters.record_artifact(client, CALL, {"path": str(target)}) == "art_new0001" + method, path, body, key = client.calls[-1] + assert (method, path) == ("POST", "/hux/v1/artifacts") + assert body["type"] == "markdown" and body["title"] == "report.md" + assert body["content"] == "# hello\n" and "content_base64" not in body + assert body["conversation_id"] == CALL.conversation_id + assert key.startswith("artifact-sync:new:") + + +def test_binary_file_is_base64_and_unknown_suffix_is_code(tmp_path): + target = tmp_path / "tool.bin" + target.write_bytes(b"\xff\xfe\x00binary") + client = Client() + emitters.record_artifact(client, CALL, {"file_path": str(target)}) + body = client.calls[-1][2] + assert body["type"] == "code" + assert base64.b64decode(body["content_base64"]) == b"\xff\xfe\x00binary" + assert "content" not in body + + +def test_existing_title_appends_an_immutable_version(tmp_path): + target = tmp_path / "main.py" + target.write_text("print(2)\n") + existing = {"id": "art_1", "title": "main.py", "revision": 3, "current_version": 3} + client = Client(items=[existing]) + assert emitters.record_artifact(client, CALL, {"path": str(target)}) == "art_1" + method, path, body, key, if_match = client.calls[-1] + assert (method, path) == ("POST", "/hux/v1/artifacts/art_1/versions") + assert body["diff_from"] == 3 and body["content"] == "print(2)\n" + assert if_match == 3 and key.startswith("artifact-sync:art_1:") + + +def test_artifact_emitter_is_bounded_and_fail_open(tmp_path): + client = Client() + # No plausible path, missing file, oversized file, symlink, service down. + assert emitters.record_artifact(client, CALL, {"other": 1}) is None + assert emitters.record_artifact(client, CALL, {"path": str(tmp_path / "gone")}) is None + big = tmp_path / "big.txt" + big.write_bytes(b"x" * (emitters.MAX_ARTIFACT_BYTES + 1)) + assert emitters.record_artifact(client, CALL, {"path": str(big)}) is None + target = tmp_path / "real.txt" + target.write_text("ok") + link = tmp_path / "link.txt" + link.symlink_to(target) + assert emitters.record_artifact(client, CALL, {"path": str(link)}) is None + assert emitters.record_artifact(client, CALL, "not-a-dict") is None + assert emitters.record_artifact(Client(fail="get"), CALL, {"path": str(target)}) is None + assert emitters.record_artifact(Client(fail="post"), CALL, {"path": str(target)}) is None + existing = {"id": "art_1", "title": "real.txt", "revision": 1, "current_version": 1} + assert emitters.record_artifact(Client(items=[existing], fail="request"), CALL, {"path": str(target)}) is None + + +def test_web_sources_are_deduplicated_and_capped(): + client = Client() + text = " ".join( + ["see https://example.org/a and https://example.org/a again,", + "plus https://example.org/b, https://example.org/c and https://example.org/d"] + ) + assert emitters.record_sources(client, text) == emitters.MAX_SOURCES_PER_CALL + posts = [call for call in client.calls if call[0] == "POST"] + assert [c[2]["uri"] for c in posts] == [ + "https://example.org/a", "https://example.org/b", "https://example.org/c", + ] + assert all(c[2]["kind"] == "web" and c[3].startswith("source-sync:") for c in posts) + + +def test_source_emitter_handles_bytes_non_text_and_failures(): + assert emitters.record_sources(Client(), b"https://example.org/bytes ok") == 1 + assert emitters.record_sources(Client(), {"not": "text"}) == 0 + assert emitters.record_sources(Client(fail="post"), "https://example.org/x") == 0 + assert emitters.record_sources(Client(), "no urls here") == 0 + + +def test_sync_router_dispatches_by_capability(tmp_path): + target = tmp_path / "out.json" + target.write_text("{}") + client = Client() + emitters.sync_after_tool(client, CALL, "write_files", {"path": str(target)}, "ok") + assert any(call[1] == "/hux/v1/artifacts" for call in client.calls) + client = Client() + emitters.sync_after_tool(client, CALL, "network", {}, "https://example.org/z") + assert any(call[1] == "/hux/v1/sources" for call in client.calls) + client = Client() + emitters.sync_after_tool(client, CALL, "shell", {}, "https://example.org/z") + assert client.calls == [] + # A broken call scope can never raise into the tool path. + emitters.sync_after_tool(None, None, "write_files", {"path": str(target)}, "ok")