Skip to content

fix(webui): keep context-engine guidance model-facing - #2825

Closed
LumenYoung wants to merge 1 commit into
nesquena:masterfrom
LumenYoung:fix/webui-context-engine-guidance-2
Closed

LumenYoung wants to merge 1 commit into
nesquena:masterfrom
LumenYoung:fix/webui-context-engine-guidance-2

Conversation

@LumenYoung

Copy link
Copy Markdown
Contributor

Summary

This PR makes WebUI preserve context-engine guidance as model-facing context after manual compression, without rendering that guidance in the visible transcript.

The motivating case is LCM/retrieval-backed compression: WebUI can successfully persist compacted summaries such as [Recent Summary ...], but because WebUI keeps its system prompt separate from conversation_history, context engines that attach guidance to a leading system message may not have a stable model-facing place to put retrieval instructions. The next model turn can then see compacted summaries without the policy that tells it to recover exact details through retrieval tools.

Why this happens

WebUI has a split that the CLI/core path does not expose in the same way:

  • messages: user-visible transcript
  • context_messages: model-facing active context
  • system_message: passed separately to run_conversation(...)

The manual compression path previously compressed only the transcript messages and then wrote the returned list to both display and model context. That works for plain summarization, but it loses the context-engine contract for engines that need to return model-facing guidance, especially when the guidance is attached to a leading system message.

For retrieval-backed summaries, this is risky: summaries are useful routing/index hints, not a complete source of exact historical facts. The model should be reminded to use retrieval before relying on compacted history.

Fix

  • Add a synthetic WebUI system anchor before manual compression so context engines have a stable place for model-facing guidance.
  • Treat compressor output as the canonical model-facing context.
  • Split compressor output into:
    • context_messages: preserves leading system guidance for the model
    • messages: strips leading system guidance so it is not rendered as a visible transcript turn
  • Add a minimal LCM fallback policy when the returned model context is LCM-like but lacks retrieval guidance.
    • The fallback is model-facing only.
    • It is idempotent.
    • It avoids non-LCM/plain contexts.

Tests

  • tests/test_sprint46.py::test_lcm_policy_helper_injects_model_facing_policy_for_summary_context
  • tests/test_sprint46.py::test_lcm_policy_helper_is_idempotent_and_avoids_non_lcm_context
  • tests/test_sprint46.py::test_session_compress_keeps_engine_system_guidance_model_facing_only
  • tests/test_sprint46.py::test_session_compress_roundtrip

Local focused run:

4 passed

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Summary

Reading the diff at cron-pr-2825 vs master, plus the existing manual-compression path at api/routes.py:10122-10470 on master, plus agent/context_engine.py and agent/context_compressor.py on the agent side: this PR addresses a real layering problem with how WebUI manual compression interacts with context engines that need to attach model-facing guidance (like LCM's retrieval policy). The fix is in three parts.

(1) Wrap the compressor input with a synthetic system anchor (_webui_manual_compression_system_message) so engines have a stable model-facing slot. (2) On the way out, split compressor output into model_context (preserves any leading system message the engine returned) and display_messages (strips it from the visible transcript). (3) Add a defensive LCM retrieval-policy injection when output looks LCM-shaped but lacks the policy text.

Code reference

The split helper at api/routes.py:86-97:

def _split_model_context_for_display(messages):
    model_context = list(messages or [])
    display_messages = list(model_context)
    if display_messages and isinstance(display_messages[0], dict) and display_messages[0].get("role") == "system":
        display_messages = display_messages[1:]
    return display_messages, model_context

The compression call-site change at api/routes.py:10427-10465 (PR head) — compressor input now includes the synthetic system message, and the two assignments diverge:

s.messages = compressed              # display: no leading system guidance
s.context_messages = compressed_context  # model context: keeps system guidance

The LCM-only fallback at api/routes.py:139-159:

def _ensure_lcm_retrieval_policy_model_context(messages, context_engine=None):
    engine_name = str(context_engine or "").lower()
    if engine_name != "lcm" and not _looks_like_lcm_model_context(model_context):
        return model_context
    if _context_has_lcm_retrieval_policy(model_context):
        return model_context
    ...

Diagnosis / Recommendation

The architectural premise is right: WebUI's messages / context_messages / system_message split is genuinely different from the CLI's conversation_history shape, and a context engine that returns model-facing guidance via a leading system message has nowhere stable to put it under the master flow. The PR fix targets that gap precisely. That said, several concerns worth raising before this lands:

  1. Heuristic detection of LCM output is fragile. _looks_like_lcm_model_context matches on literal substrings: "[Recent Summary", "[Session Arc Summary", "[Expand for details". These are tied to the current LCM plugin's output format. If the LCM plugin renames a tag or another engine produces compatible-looking output, this misfires. The PR partly hedges by also accepting engine_name == "lcm" directly, but the engine-name check runs through getattr(agent.context_compressor, "name", None) (api/routes.py:10433) and on the agent side context_compressor is the compressor not the engine — those are different objects (agent/context_engine.py:15-25, 160).

    Recommend tightening this: pull the engine name from getattr(s, "context_engine", None) first (the session's persisted choice from api/models.py:411-460), and only fall back to substring detection if that's missing.

  2. The fallback policy text is hand-rolled in WebUI. Lines :147-156 hard-code the LCM retrieval-policy string in WebUI:

    policy = (
        "[LCM retrieval policy: Earlier turns have been compacted into "
        "retrieval-backed summaries. Before relying on compacted history, use "
        "lcm_grep to search, lcm_describe to inspect candidate nodes, ...")

    This text duplicates whatever the LCM plugin canonically emits. If the agent side updates the wording (a new tool name, different argument shape) WebUI's copy goes stale silently. The cleaner layering would be a hook on the agent side — LCMContextEngine.get_retrieval_policy_message() or similar — and WebUI imports it via the plugin contract. As a near-term safety net the hardcoded copy is defensible; as a permanent layer, the duplication is a future bug source.

  3. Verify the system anchor doesn't double-wrap. _webui_manual_compression_system_message(s) (api/routes.py:71-83) builds a workspace-anchored system message and prepends it to original_messages. The agent's context_compressor.compress(...) may itself add a leading system message if one is missing, or it may keep the caller's intact. Worth verifying with the LCM compressor specifically (hermes-agent/agent/context_compressor.py) that the prepended anchor isn't passed through as a summary input by the compressor — if the compressor treats input messages as candidates for summarization, the workspace anchor could end up summarized away or merged into a compacted summary.

  4. Test coverage is structural, not behavioral. The four added tests in tests/test_sprint46.py exercise the helpers but use a _FakeCompressor that mimics what a real engine should return. Recommend adding one test against the real LCM plugin path (or at least a more realistic compressor double that returns the actual [Recent Summary ...] / [Expand for details] markers) to confirm the heuristic detection trips correctly end-to-end.

  5. Behavior for non-LCM engines. _ensure_lcm_retrieval_policy_model_context returns model_context unchanged when neither name nor heuristic matches. Good. That preserves backwards compatibility for the default summarizer path.

Test plan

pytest tests/test_sprint46.py -q

plus a manual round-trip with a real LCM-enabled session: trigger manual compression via the WebUI, confirm s.messages does not contain the leading system message in the rendered transcript, and confirm the next assistant turn sees the LCM retrieval-policy guidance (visible in the model API request payload via the dev panel or /api/logs).

Strong shape, layering concerns above worth addressing before merge.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Deferred to a future iteration — concerns from review need a pass before merge

Thanks @LumenYoung — the architectural premise (WebUI's messages / context_messages / system_message split needs a stable model-facing slot for context engines that emit guidance) is correct, and the three-part fix shape is right. We're not closing this; just flagging it for another iteration before it ships.

Concerns to address before merge

Following the review's per-claim analysis:

  1. LCM heuristic fragility. _looks_like_lcm_model_context matches on literal substrings ("[Recent Summary", "[Session Arc Summary", "[Expand for details"). These are tied to the current LCM plugin's output format. If the plugin renames a tag or another engine produces compatible-looking output, the detection misfires. Recommended fix: pull the engine name from getattr(s, "context_engine", None) (the session's persisted choice from api/models.py:411-460) first, and only fall back to substring detection if that's missing. The current agent.context_compressor lookup at api/routes.py:10433 is querying the compressor not the engine — different objects per agent/context_engine.py:15-25, 160 — so the engine-name path doesn't currently work.

  2. Hand-rolled policy text duplicated from the agent. Lines :147-156 hard-code the LCM retrieval-policy string in WebUI. If the agent side updates the wording (new tool name, different argument shape) WebUI's copy goes stale silently. Recommended fix: add a hook on the agent side — LCMContextEngine.get_retrieval_policy_message() or similar — and have WebUI import it via the plugin contract. The current near-term hardcoded copy is defensible only as a short-lived bridge, not a permanent layer.

  3. Verify the system anchor doesn't double-wrap. _webui_manual_compression_system_message(s) builds a workspace-anchored system message and prepends it to original_messages. The agent's context_compressor.compress(...) may itself add a leading system message if one is missing, or it may keep the caller's intact. Recommended verification: add a test specifically against agent/context_compressor.py confirming the prepended anchor isn't passed through as a summary input by the compressor — if the compressor treats input messages as candidates for summarization, the workspace anchor could end up summarized away or merged into a compacted summary.

  4. Test coverage is structural, not behavioral. The four tests in tests/test_sprint46.py exercise the helpers but use a _FakeCompressor that mimics what a real engine should return. Recommended addition: one test against the real LCM plugin path (or at least a more realistic compressor double that returns the actual [Recent Summary ...] / [Expand for details] markers) to confirm the heuristic detection trips correctly end-to-end. Hits the same class of "mock-vs-production" trap that's caught us a few times — see the agent-side-empirical-verification pattern.

  5. Behavior for non-LCM engines. _ensure_lcm_retrieval_policy_model_context returns model_context unchanged when neither name nor heuristic matches. That's the right default. Confirming this preserves backwards-compatibility for the summarizer path — no action needed, just calling out it was checked.

What works as-is

  • The three-part architectural shape: synthetic system anchor → split compressor output → defensive LCM injection. Each piece is at the right layer.
  • The split helper _split_model_context_for_display at api/routes.py:86-97 is clean.
  • s.messages vs s.context_messages divergence at api/routes.py:10427-10465 is the right call — preserves model-facing guidance without polluting the visible transcript.

Path forward

Two viable next steps:

  1. You iterate the PR yourself — wire engine-name through s.context_engine, drop the hand-rolled policy text in favor of an agent-side hook (we can land the agent-side get_retrieval_policy_message() in parallel if you want to chase that direction), add the agent-side test, push, re-review.

  2. We absorb it as maintainer-augmented — I can take your PR head onto a stage branch, apply the four fixes from the review, ship as a Co-authored-by: @LumenYoung release. Lower coordination overhead but you lose direct iteration on the design space.

My read: (1) is the cleaner path because the engine-name plumbing question genuinely needs your judgment about how the LCM plugin should expose its policy text — that's a contract design call, not a mechanical fix.

Applying hold + maintainer-review until the engine-name and policy-text questions are settled. Not closing the PR — this is a real fix, just needs one more iteration.

@LumenYoung
LumenYoung force-pushed the fix/webui-context-engine-guidance-2 branch from f76d976 to c887529 Compare May 25, 2026 05:55
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Thanks @LumenYoung — you've identified a real gap: WebUI keeps its system prompt separate from conversation_history, so a context engine's retrieval guidance can lose its model-facing home after manual compression, leaving the model with compacted summaries but not the policy telling it to recover details via retrieval tools. That's a legitimate problem.

Closing this implementation, though, because the detection mechanism is too brittle to maintain. _looks_like_lcm_model_context() recognizes LCM output by sniffing for literal magic strings — "[Recent Summary", "[Session Arc Summary", "[Expand for details". If the LCM plugin ever renames a tag (or another engine emits compatible-looking output), this silently breaks with no error, and the WebUI becomes hard-coupled to one plugin's exact output format. The benefit also reaches only a narrow slice (users who use LCM and manually compress in the WebUI).

The durable fix belongs on the context-engine contract side: the engine should emit its model-facing guidance through a stable, structured signal (e.g. a dedicated context_messages guidance field) that WebUI passes through verbatim — rather than WebUI guessing from substrings. That keeps the coupling explicit and rename-proof.

Closing with thanks for the clear write-up of the underlying split — if you (or anyone) want to pursue the structured-signal version, that'd be very welcome. Crediting your analysis here for whoever picks it up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hold maintainer-review Maintainer fit-assessment needed — may not merge even with fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants