diff --git a/CHANGELOG.md b/CHANGELOG.md index d2af42d7..f1cde3a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Oversized WebUI startup prefill payloads now respect a configurable context budget (`webui_prefill_context_max_chars` / `HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS`, default 12,000 chars). When a dynamic prefill script exceeds the budget and a compact static prefill file is configured, WebUI falls back to the compact file; otherwise it injects a small retrieval instruction instead of dumping the full note/body payload into every new chat. + ## [v0.51.155] — 2026-05-28 — Release EA (stage-batch37 — 3-PR very low-risk cleanup: passive timeout toasts + sidecar order + subsecond timestamps) ### Fixed diff --git a/api/streaming.py b/api/streaming.py index df515511..2a0a28f6 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -292,6 +292,74 @@ def _resolve_prefill_path(raw: str) -> Path: _PREFILL_SCRIPT_OUTPUT_LIMIT = 262_144 +_PREFILL_CONTEXT_DEFAULT_MAX_CHARS = 12_000 + + +def _prefill_context_max_chars(config_data: dict) -> int: + raw = os.getenv("HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS", "") or str( + config_data.get("webui_prefill_context_max_chars") or "" + ) + try: + value = int(raw or _PREFILL_CONTEXT_DEFAULT_MAX_CHARS) + except Exception: + value = _PREFILL_CONTEXT_DEFAULT_MAX_CHARS + return max(0, min(value, _PREFILL_SCRIPT_OUTPUT_LIMIT)) + + +def _prefill_context_char_count(messages: list[dict]) -> int: + return sum(len(str(message.get("content") or "")) for message in messages if isinstance(message, dict)) + + +def _budget_compacted_prefill_context(context: dict, *, max_chars: int, char_count: int) -> dict: + label = str(context.get("label") or "prefill context") + message = ( + "A configured WebUI startup prefill source was available, but it exceeded " + f"the WebUI prefill context budget ({char_count} chars > {max_chars} chars), " + "so the note/body payload was omitted from this new chat. If the user's " + "request depends on prior decisions, durable notes, runbooks, current " + "context, or open issues, use the available retrieval/search/note tools " + "to fetch only the relevant details before answering." + ) + return { + "status": "loaded", + "source": "budget_compacted", + "label": label, + "messages": [{"role": "user", "content": message}], + "message_count": 1, + "compacted": True, + "original_source": context.get("source", ""), + "original_message_count": int(context.get("message_count") or 0), + "original_char_count": char_count, + "max_chars": max_chars, + } + + +def _apply_prefill_context_budget(context: dict, config_data: dict) -> dict: + if context.get("status") != "loaded": + return context + max_chars = _prefill_context_max_chars(config_data) + if max_chars <= 0: + return context + messages = context.get("messages") or [] + char_count = _prefill_context_char_count(messages if isinstance(messages, list) else []) + if char_count <= max_chars: + return context + + file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(config_data.get("prefill_messages_file") or "") + if context.get("source") == "script" and file_raw: + fallback = _load_prefill_messages_file(file_raw, source="file_budget_fallback") + fallback_messages = fallback.get("messages") if isinstance(fallback, dict) else [] + fallback_chars = _prefill_context_char_count(fallback_messages if isinstance(fallback_messages, list) else []) + if fallback.get("status") == "loaded" and fallback_chars <= max_chars: + fallback["compacted"] = True + fallback["original_source"] = context.get("source", "") + fallback["original_label"] = context.get("label", "") + fallback["original_message_count"] = int(context.get("message_count") or 0) + fallback["original_char_count"] = char_count + fallback["max_chars"] = max_chars + return fallback + + return _budget_compacted_prefill_context(context, max_chars=max_chars, char_count=char_count) def _prefill_not_configured() -> dict: @@ -397,10 +465,10 @@ def _load_webui_prefill_context( cfg = config_data if isinstance(config_data, dict) else get_config() script_context = _load_prefill_messages_script(cfg) if script_context.get("status") != "not_configured": - return script_context + return _apply_prefill_context_budget(script_context, cfg) file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "") if file_raw: - return _load_prefill_messages_file(file_raw) + return _apply_prefill_context_budget(_load_prefill_messages_file(file_raw), cfg) return _prefill_not_configured() @@ -412,6 +480,11 @@ def _public_prefill_context_status(prefill_context: dict) -> dict: "label": prefill_context.get("label", ""), "message_count": int(prefill_context.get("message_count") or 0), **({"error": prefill_context.get("error", "")} if prefill_context.get("error") else {}), + **({"compacted": True} if prefill_context.get("compacted") else {}), + **({"original_source": prefill_context.get("original_source", "")} if prefill_context.get("original_source") else {}), + **({"original_message_count": int(prefill_context.get("original_message_count") or 0)} if prefill_context.get("original_message_count") else {}), + **({"original_char_count": int(prefill_context.get("original_char_count") or 0)} if prefill_context.get("original_char_count") else {}), + **({"max_chars": int(prefill_context.get("max_chars") or 0)} if prefill_context.get("max_chars") else {}), } diff --git a/tests/test_webui_prefill_context.py b/tests/test_webui_prefill_context.py index 9c2b4aad..cb1e5649 100644 --- a/tests/test_webui_prefill_context.py +++ b/tests/test_webui_prefill_context.py @@ -147,6 +147,80 @@ def test_webui_prefill_script_rejects_oversized_stdout(tmp_path): assert "output exceeded" in result["error"] +def test_webui_prefill_script_over_budget_uses_static_file_fallback(tmp_path): + from api.streaming import _load_webui_prefill_context, _public_prefill_context_status + + prefill = tmp_path / "router.json" + prefill.write_text(json.dumps([{"role": "user", "content": "Compact router context"}]), encoding="utf-8") + script = tmp_path / "large_recall.py" + script.write_text("print('x' * 80)\n", encoding="utf-8") + + result = _load_webui_prefill_context( + { + "webui_prefill_messages_script": [sys.executable, str(script)], + "prefill_messages_file": str(prefill), + "webui_prefill_context_max_chars": 40, + } + ) + + assert result["status"] == "loaded" + assert result["source"] == "file_budget_fallback" + assert result["messages"] == [{"role": "user", "content": "Compact router context"}] + assert result["compacted"] is True + assert result["original_source"] == "script" + assert result["original_char_count"] == 80 + public = _public_prefill_context_status(result) + assert public["compacted"] is True + assert public["original_char_count"] == 80 + assert "messages" not in public + + +def test_webui_prefill_file_over_budget_compacts_without_leaking_body(tmp_path): + from api.streaming import _load_webui_prefill_context, _public_prefill_context_status + + prefill = tmp_path / "huge.json" + prefill.write_text(json.dumps([{"role": "user", "content": "secret project note " * 20}]), encoding="utf-8") + + result = _load_webui_prefill_context( + { + "prefill_messages_file": str(prefill), + "webui_prefill_context_max_chars": 50, + } + ) + + assert result["status"] == "loaded" + assert result["source"] == "budget_compacted" + assert result["message_count"] == 1 + assert result["compacted"] is True + assert result["original_source"] == "file" + assert result["original_char_count"] > 50 + compact_message = result["messages"][0]["content"] + assert "exceeded the WebUI prefill context budget" in compact_message + assert "secret project note" not in compact_message + public = _public_prefill_context_status(result) + assert public["source"] == "budget_compacted" + assert public["compacted"] is True + assert "messages" not in public + + +def test_webui_prefill_context_budget_can_be_disabled(tmp_path): + from api.streaming import _load_webui_prefill_context + + prefill = tmp_path / "huge.json" + prefill.write_text(json.dumps([{"role": "user", "content": "x" * 80}]), encoding="utf-8") + + result = _load_webui_prefill_context( + { + "prefill_messages_file": str(prefill), + "webui_prefill_context_max_chars": 0, + } + ) + + assert result["source"] == "file" + assert result["messages"] == [{"role": "user", "content": "x" * 80}] + assert "compacted" not in result + + def test_public_prefill_status_strips_message_bodies(): from api.streaming import _public_prefill_context_status