Skip to content

fix: enforce system-message-first invariant across standalone LLM calls (#5145-#5150) - #5231

Closed
juanmichelini wants to merge 1 commit into
mainfrom
fix/system-message-invariant
Closed

juanmichelini wants to merge 1 commit into
mainfrom
fix/system-message-invariant

Conversation

@juanmichelini

@juanmichelini juanmichelini commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes the repo-wide invariant that every LLM request built in this repository must place a system message before the first user message.

Tracking issue: #5144. This PR addresses all six open subissues (#5145–#5150).

Note: PR #5143 (DRAFT, by the user, for #5142 — the condenser summarization system/user split) is a separate, overlapping change. This PR covers the condenser protection from a different angle (preserving the leading SystemPromptEvent through condensation and hard resets) and does not depend on #5143 being merged.

Changes by subissue

#5145 — Goal judge: split prompt into system + user

  • openhands/sdk/conversation/goal/prompts.py: split the single JUDGE_PROMPT into JUDGE_SYSTEM_PROMPT (steering/format instructions) and JUDGE_USER_PROMPT (objective + transcript payload template). JUDGE_PROMPT retained for backwards compatibility.
  • openhands/sdk/conversation/goal/judge.py: judge_goal() now sends [Message(role="system", ...), Message(role="user", ...)].

#5146 — Profiles router pre-flight ping needs a system message

  • openhands/agent_server/profiles_router.py: the /validate pre-flight ping now sends a system message before the user "ping".

#5147 — GraySwan analyzer needs a system message

  • openhands/sdk/security/grayswan/analyzer.py: when the bounded history window would slice off the leading SystemPromptEvent, it is re-included at the head of the window. As a fallback, if no system prompt is available at all, a minimal system message is synthesized so the request is still system-first.

#5148 — Condenser keep_first=0 must protect the system prompt

  • openhands/sdk/context/condenser/llm_summarizing_condenser.py: _get_forgotten_events() floors the forgetting range start past the leading SystemPromptEvent, so keep_first=0 no longer allows it to be forgotten.

#5149 — Condenser hard reset must preserve the system prompt

  • openhands/sdk/context/condenser/llm_summarizing_condenser.py: hard_context_reset() and ahard_context_reset() now preserve the leading SystemPromptEvent and insert the summary behind it (summary_offset = system_index + 1) instead of summarizing the entire view (including the system prompt) at offset 0 — which previously produced a user-role summary as the first message.

#5150 — Document the invariant

  • DEVELOPMENT.md: new "LLM message construction invariant" section describing the rule, why it matters, and documented exceptions (ACP agents, subscription/Codex transport).
  • AGENTS.md: added the rule to the Review-Facing Implementation Checklist.

Tests

Added regression tests for each path:

  • tests/sdk/conversation/goal/test_judge.py — judge sends [system, user].
  • tests/agent_server/test_profiles_router.py — pre-flight ping is system-first.
  • tests/sdk/security/grayswan/test_grayswan_analyzer.py — window preserves real system prompt; placeholder synthesized when absent.
  • tests/sdk/context/condenser/test_llm_summarizing_condenser.py — keep_first=0 keeps system first; hard reset (sync + async) preserves system prompt.

All affected suites pass (199 tests). Two pre-existing failures (test_utility_llm_span_metadata, test_remote_conversation_raises_when_websocket_never_ready) fail identically on main without these changes (OTEL/WebSocket test-environment issues) and are unrelated.

This PR was created by an AI agent (OpenHands) on behalf of the user.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 python-node-runtime Link
python-minimal amd64, arm64 python-node-runtime Link
python amd64, arm64 python-node-runtime Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:485d63b-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-485d63b-python \
  ghcr.io/openhands/agent-server:485d63b-python

All tags pushed for this build

ghcr.io/openhands/agent-server:485d63b-golang-amd64
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-golang-amd64
ghcr.io/openhands/agent-server:fix-system-message-invariant-golang-amd64
ghcr.io/openhands/agent-server:485d63b-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:485d63b-golang-arm64
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-golang-arm64
ghcr.io/openhands/agent-server:fix-system-message-invariant-golang-arm64
ghcr.io/openhands/agent-server:485d63b-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:485d63b-java-amd64
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-java-amd64
ghcr.io/openhands/agent-server:fix-system-message-invariant-java-amd64
ghcr.io/openhands/agent-server:485d63b-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:485d63b-java-arm64
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-java-arm64
ghcr.io/openhands/agent-server:fix-system-message-invariant-java-arm64
ghcr.io/openhands/agent-server:485d63b-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:485d63b-python-amd64
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-python-amd64
ghcr.io/openhands/agent-server:fix-system-message-invariant-python-amd64
ghcr.io/openhands/agent-server:485d63b-python-node-runtime-amd64
ghcr.io/openhands/agent-server:485d63b-python-arm64
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-python-arm64
ghcr.io/openhands/agent-server:fix-system-message-invariant-python-arm64
ghcr.io/openhands/agent-server:485d63b-python-node-runtime-arm64
ghcr.io/openhands/agent-server:485d63b-python-minimal-amd64
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-python-minimal-amd64
ghcr.io/openhands/agent-server:fix-system-message-invariant-python-minimal-amd64
ghcr.io/openhands/agent-server:485d63b-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:485d63b-python-minimal-arm64
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-python-minimal-arm64
ghcr.io/openhands/agent-server:fix-system-message-invariant-python-minimal-arm64
ghcr.io/openhands/agent-server:485d63b-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:485d63b-python-slim-amd64
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-python-slim-amd64
ghcr.io/openhands/agent-server:fix-system-message-invariant-python-slim-amd64
ghcr.io/openhands/agent-server:485d63b-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:485d63b-python-slim-arm64
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-python-slim-arm64
ghcr.io/openhands/agent-server:fix-system-message-invariant-python-slim-arm64
ghcr.io/openhands/agent-server:485d63b-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:485d63b-golang
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-golang
ghcr.io/openhands/agent-server:fix-system-message-invariant-golang
ghcr.io/openhands/agent-server:485d63b-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:485d63b-java
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-java
ghcr.io/openhands/agent-server:fix-system-message-invariant-java
ghcr.io/openhands/agent-server:485d63b-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:485d63b-python-minimal
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-python-minimal
ghcr.io/openhands/agent-server:fix-system-message-invariant-python-minimal
ghcr.io/openhands/agent-server:485d63b-python-node-runtime-minimal
ghcr.io/openhands/agent-server:485d63b-python-slim
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-python-slim
ghcr.io/openhands/agent-server:fix-system-message-invariant-python-slim
ghcr.io/openhands/agent-server:485d63b-python-node-runtime-slim
ghcr.io/openhands/agent-server:485d63b-python
ghcr.io/openhands/agent-server:485d63b42bde0cf9af3cb547c60db0659a1835d1-python
ghcr.io/openhands/agent-server:fix-system-message-invariant-python
ghcr.io/openhands/agent-server:485d63b-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., 485d63b-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 485d63b-python-amd64) are also available if needed

Fixes the repo-wide invariant that every LLM request must place a system
message before the first user message. Addresses subissues #5145-#5150 of
tracking issue #5144.

- #5145: Split the goal judge's single prompt into JUDGE_SYSTEM_PROMPT
  (steering) + JUDGE_USER_PROMPT (payload); judge_goal() now sends a
  [system, user] message pair. JUDGE_PROMPT kept for backwards compat.
- #5146: profiles_router pre-flight ping now sends a system message
  before the user 'ping'.
- #5147: GraySwan analyzer re-includes the leading SystemPromptEvent
  when the bounded history window would drop it, and synthesizes a
  minimal system message if none is available.
- #5148: LLMSummarizingCondenser._get_forgotten_events() floors the
  forgetting range past the leading SystemPromptEvent, so keep_first=0
  no longer lets it be forgotten.
- #5149: hard_context_reset()/ahard_context_reset() preserve the leading
  SystemPromptEvent and place the summary behind it (offset = system+1)
  instead of summarizing the whole view at offset 0.
- #5150: Document the invariant in DEVELOPMENT.md and AGENTS.md.

Added regression tests for each path.

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   profiles_router.py196697%503–508
openhands-sdk/openhands/sdk/context/condenser
   llm_summarizing_condenser.py20012637%40–43, 93, 109, 113, 153, 160, 167, 171, 188–189, 194–196, 202–203, 226, 229, 234, 240, 244–247, 252–256, 258, 281–282, 284, 286–288, 290–292, 294, 298–300, 302–303, 305, 316, 319, 324–327, 330, 333, 336, 339, 356–359, 361–362, 364–366, 371, 374–375, 378–379, 384, 389, 391–392, 401–402, 405–406, 410–411, 417–418, 423, 439, 441, 446, 452, 454–457, 461–465, 467, 478–479, 482–483, 487–488, 495–496, 501, 513–516, 518–519, 521–523, 528–532, 535, 539, 541–542
openhands-sdk/openhands/sdk/conversation/goal
   judge.py513335%59–62, 67–68, 71, 77–79, 89, 95, 100, 102–112, 114–116, 120–124, 126
openhands-sdk/openhands/sdk/security/grayswan
   analyzer.py1249027%85–86, 90, 96–101, 106–112, 114, 125, 129–130, 141–145, 156–159, 161, 172–174, 176–177, 179, 181, 186, 188–193, 195, 198–200, 202, 205–207, 211, 215, 217, 220, 222–227, 242, 246–248, 250, 253–255, 257–258, 261, 263, 265, 268, 271, 273–275, 281–282, 290, 294, 296–298, 302–304
TOTAL443471834559% 

@juanmichelini

Copy link
Copy Markdown
Collaborator Author

Closing in favor of one PR per subissue (#5145-#5150), as requested.

@juanmichelini
juanmichelini deleted the fix/system-message-invariant branch September 23, 2026 01:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants