fix: budget webui prefill context

This commit is contained in:
AJV20
2026-05-28 15:26:35 -04:00
parent 20f8c7c02d
commit d539d5c079
3 changed files with 153 additions and 2 deletions
+4
View File
@@ -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
+75 -2
View File
@@ -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 {}),
}
+74
View File
@@ -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