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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
parent
2eedcd2066
commit
c089a5ec2a
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
142
services/hermes/plugins/hux-runtime/emitters.py
Normal file
142
services/hermes/plugins/hux-runtime/emitters.py
Normal file
@ -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
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
]
|
||||
|
||||
140
testing/tests/test_hermes_hux_runtime_emitters.py
Normal file
140
testing/tests/test_hermes_hux_runtime_emitters.py
Normal file
@ -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")
|
||||
Loading…
x
Reference in New Issue
Block a user