Simplify: don't promote developer messages to system instruction

Developer messages are now always converted to "user" in non-OpenAI
adapters, never promoted to the system instruction. This removes an
inconsistency where adding an unrelated message to context would change
whether a developer message got promoted.

Simplifications:
- Rename _extract_initial_system_or_developer → _extract_initial_system
- Return Optional[str] instead of Tuple (role is always "system")
- Drop initial_context_message_role from _resolve_system_instruction
- Drop system_role fields from all ConvertedMessages dataclasses
This commit is contained in:
Paul Kompfner
2026-03-20 15:58:34 -04:00
parent a0393b9af6
commit 2135557689
7 changed files with 98 additions and 149 deletions

View File

@@ -11,7 +11,7 @@ adapters that handle tool format conversion and standardization.
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar
from typing import Any, Dict, Generic, List, Optional, TypeVar
from loguru import logger
@@ -135,60 +135,46 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]):
# Fallback to return the same tools in case they are not in a standard format
return tools
def _extract_initial_system_or_developer(
def _extract_initial_system(
self,
messages: list,
*,
system_instruction: Optional[str],
) -> Tuple[Optional[str], Optional[str]]:
"""Extract an initial system/developer message for use as a system instruction.
system_instruction: Optional[str] = None,
) -> Optional[str]:
"""Extract an initial ``"system"`` message for use as a system instruction.
Only useful for services that expect the system instruction as a
separate parameter, not inline in conversation history (today, all
non-OpenAI services).
non-OpenAI services). Does not extract ``"developer"`` messages —
those are converted to ``"user"`` by the adapter's subsequent message
loop, like any other non-system role the provider doesn't support.
Checks ``messages[0]``. Behavior:
- ``"system"`` role: assumed to be intended as the system instruction.
Extract (pop from messages).
- ``"developer"`` role **without** ``system_instruction``: also assumed
to be intended as the system instruction. Extract (pop).
- ``"developer"`` role **with** ``system_instruction``: assumed to be
intended as a conversation-history message (since a system instruction
is already provided). Don't extract; convert to ``"user"`` in-place.
- Any other role: no-op.
If extracting would leave the messages list empty
(``len(messages) == 1``), the message is converted to ``"user"`` role
instead of being extracted. This prevents sending an empty conversation
history to providers that require at least one non-system message.
Checks ``messages[0]``. If the role is ``"system"``, pops and returns
its content. If extracting would leave the messages list empty
(``len(messages) == 1``), the message is converted to ``"user"``
role instead of being extracted, to prevent sending an empty
conversation history to providers that require at least one
non-system message.
Args:
messages: Message list in standard format (mutated in-place).
system_instruction: The system instruction from service settings
or ``run_inference``, used to decide whether to extract a
``"developer"`` message.
or ``run_inference``. Only used to decide whether to warn
about a conflict in the single-message case.
Returns:
``(extracted_content, original_role)`` where *original_role* is
``"system"`` or ``"developer"``, or ``(None, None)`` if nothing
The extracted system message content, or ``None`` if nothing
was extracted.
"""
if not messages:
return None, None
return None
role = messages[0].get("role")
if role not in ("system", "developer"):
return None, None
# "developer" + system_instruction present → keep in messages as "user"
if role == "developer" and system_instruction:
messages[0]["role"] = "user"
return None, None
if messages[0].get("role") != "system":
return None
# Would extracting empty the list? Convert to "user" instead.
if len(messages) == 1:
if role == "system" and system_instruction:
if system_instruction:
if not self._warned_system_instruction:
self._warned_system_instruction = True
logger.warning(
@@ -198,7 +184,7 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]):
" history."
)
messages[0]["role"] = "user"
return None, None
return None
# Extract
content = messages[0].get("content", "")
@@ -208,28 +194,21 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]):
part.get("text", "") for part in content if part.get("type") == "text"
)
messages.pop(0)
return content, role
return content
def _resolve_system_instruction(
self,
initial_context_message: Optional[str],
initial_context_message_role: Optional[str],
system_from_context: Optional[str],
system_instruction: Optional[str],
*,
discard_context_system: bool,
) -> Optional[str]:
"""Resolve conflict between ``system_instruction`` and an initial context message.
Only warns when *initial_context_message_role* is ``"system"`` (not
``"developer"``), since a developer message coexisting with
``system_instruction`` is expected and handled elsewhere.
"""Resolve conflict between ``system_instruction`` and an extracted context system message.
Args:
initial_context_message: Content extracted from ``messages[0]``
by :meth:`_extract_initial_system_or_developer`, or detected
system_from_context: Content extracted from an initial ``"system"``
message by :meth:`_extract_initial_system`, or detected
inline (OpenAI adapters).
initial_context_message_role: ``"system"`` or ``"developer"`` —
the original role before extraction/detection.
system_instruction: From service settings or ``run_inference`` param.
discard_context_system: If ``True`` (non-OpenAI adapters), the
context system message is discarded when ``system_instruction``
@@ -239,10 +218,7 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]):
The effective system instruction to use, or ``None`` if the system
instruction is already represented in the messages (OpenAI path).
"""
both_present = initial_context_message and system_instruction
from_system_role = initial_context_message_role == "system"
if both_present and from_system_role:
if system_from_context and system_instruction:
if not self._warned_system_instruction:
self._warned_system_instruction = True
if discard_context_system:
@@ -257,15 +233,11 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]):
)
if system_instruction:
if discard_context_system:
return system_instruction
else:
# OpenAI path: caller prepends; return the instruction for prepending
return system_instruction
return system_instruction
if initial_context_message:
if system_from_context:
if discard_context_system:
return initial_context_message
return system_from_context
else:
# Content is already in messages; nothing to prepend
return None

View File

@@ -69,7 +69,6 @@ class AnthropicLLMAdapter(BaseLLMAdapter[AnthropicLLMInvocationParams]):
)
system = self._resolve_system_instruction(
converted.system if converted.system is not NOT_GIVEN else None,
converted.system_role,
system_instruction,
discard_context_system=True,
)
@@ -118,7 +117,6 @@ class AnthropicLLMAdapter(BaseLLMAdapter[AnthropicLLMInvocationParams]):
messages: List[MessageParam]
system: str | NotGiven
system_role: Optional[str] = None # "system" or "developer" — origin of extracted system
def _from_universal_context_messages(
self,
@@ -127,18 +125,16 @@ class AnthropicLLMAdapter(BaseLLMAdapter[AnthropicLLMInvocationParams]):
system_instruction: Optional[str] = None,
) -> ConvertedMessages:
system = NOT_GIVEN
system_role = None
# Extract initial system/developer from universal messages BEFORE conversion,
# Extract initial system message from universal messages BEFORE conversion,
# so the helper works with standard message format (not provider-specific).
remaining = list(universal_context_messages)
if remaining and not isinstance(remaining[0], LLMSpecificMessage):
extracted_content, extracted_role = self._extract_initial_system_or_developer(
extracted = self._extract_initial_system(
remaining, system_instruction=system_instruction
)
if extracted_content is not None:
system = extracted_content
system_role = extracted_role
if extracted is not None:
system = extracted
# Convert remaining messages to Anthropic format
messages = []
@@ -180,7 +176,7 @@ class AnthropicLLMAdapter(BaseLLMAdapter[AnthropicLLMInvocationParams]):
elif isinstance(message["content"], list) and len(message["content"]) == 0:
message["content"] = [{"type": "text", "text": "(empty)"}]
return self.ConvertedMessages(messages=messages, system=system, system_role=system_role)
return self.ConvertedMessages(messages=messages, system=system)
def _from_universal_context_message(self, message: LLMContextMessage) -> MessageParam:
if isinstance(message, LLMSpecificMessage):

View File

@@ -65,7 +65,6 @@ class AWSBedrockLLMAdapter(BaseLLMAdapter[AWSBedrockLLMInvocationParams]):
)
effective_system = self._resolve_system_instruction(
converted.system,
converted.system_role,
system_instruction,
discard_context_system=True,
)
@@ -112,7 +111,6 @@ class AWSBedrockLLMAdapter(BaseLLMAdapter[AWSBedrockLLMInvocationParams]):
messages: List[dict[str, Any]]
system: Optional[str]
system_role: Optional[str] = None # "system" or "developer" — origin of extracted system
def _from_universal_context_messages(
self,
@@ -121,18 +119,12 @@ class AWSBedrockLLMAdapter(BaseLLMAdapter[AWSBedrockLLMInvocationParams]):
system_instruction: Optional[str] = None,
) -> ConvertedMessages:
system = None
system_role = None
# Extract initial system/developer from universal messages BEFORE conversion,
# Extract initial system message from universal messages BEFORE conversion,
# so the helper works with standard message format (not provider-specific).
remaining = list(universal_context_messages)
if remaining and not isinstance(remaining[0], LLMSpecificMessage):
extracted_content, extracted_role = self._extract_initial_system_or_developer(
remaining, system_instruction=system_instruction
)
if extracted_content is not None:
system = extracted_content
system_role = extracted_role
system = self._extract_initial_system(remaining, system_instruction=system_instruction)
# Convert remaining messages to Bedrock format
messages = []
@@ -174,7 +166,7 @@ class AWSBedrockLLMAdapter(BaseLLMAdapter[AWSBedrockLLMInvocationParams]):
elif isinstance(message["content"], list) and len(message["content"]) == 0:
message["content"] = [{"type": "text", "text": "(empty)"}]
return self.ConvertedMessages(messages=messages, system=system, system_role=system_role)
return self.ConvertedMessages(messages=messages, system=system)
def _from_universal_context_message(self, message: LLMContextMessage) -> dict[str, Any]:
if isinstance(message, LLMSpecificMessage):

View File

@@ -71,7 +71,6 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
)
effective_system = self._resolve_system_instruction(
converted.system_instruction,
converted.system_instruction_role,
system_instruction,
discard_context_system=True,
)
@@ -176,7 +175,6 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
messages: List[Content]
system_instruction: Optional[str] = None
system_instruction_role: Optional[str] = None # "system" or "developer"
@dataclass
class MessageConversionResult:
@@ -220,12 +218,11 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
# We work on a mutable copy so we can pop messages[0] if needed.
remaining_messages = list(universal_context_messages)
extracted_system = None
extracted_role = None
# Extract initial system/developer from universal messages BEFORE conversion,
# Extract initial system message from universal messages BEFORE conversion,
# so the helper works with standard message format.
if remaining_messages and not isinstance(remaining_messages[0], LLMSpecificMessage):
extracted_system, extracted_role = self._extract_initial_system_or_developer(
extracted_system = self._extract_initial_system(
remaining_messages, system_instruction=system_instruction
)
@@ -294,7 +291,6 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
return self.ConvertedMessages(
messages=messages,
system_instruction=extracted_system,
system_instruction_role=extracted_role,
)
def _from_standard_message(

View File

@@ -68,11 +68,13 @@ class OpenAILLMAdapter(BaseLLMAdapter[OpenAILLMInvocationParams]):
if system_instruction:
# Detect initial system message for warning purposes (don't extract)
initial_role = messages[0].get("role") if messages else None
initial_content = messages[0].get("content", "") if initial_role == "system" else None
initial_content = (
messages[0].get("content", "")
if messages and messages[0].get("role") == "system"
else None
)
self._resolve_system_instruction(
initial_content,
initial_role if initial_role == "system" else None,
system_instruction,
discard_context_system=False,
)

View File

@@ -68,7 +68,6 @@ class OpenAIResponsesLLMAdapter(BaseLLMAdapter[OpenAIResponsesLLMInvocationParam
if first_msg and first_msg.get("role") == "system":
self._resolve_system_instruction(
first_msg.get("content", ""),
"system",
system_instruction,
discard_context_system=False,
)