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,
)

View File

@@ -25,7 +25,7 @@ For Gemini adapter:
5. Single system instruction is converted to user message when no other messages exist
6. Multiple system instructions: first extracted, later ones converted to user messages
7. system_instruction overrides context system message, with conflict warnings
8. Developer messages are promoted to system instruction or converted to user
8. Developer messages are converted to user
For Anthropic adapter:
1. LLMStandardMessage objects are converted to Anthropic MessageParam format
@@ -35,7 +35,7 @@ For Anthropic adapter:
5. Consecutive messages with same role are merged into multi-content-block messages
6. Empty text content is converted to "(empty)"
7. system_instruction overrides context system message, with conflict warnings
8. Developer messages are promoted to system instruction or converted to user
8. Developer messages are converted to user
For AWS Bedrock adapter:
1. LLMStandardMessage objects are converted to AWS Bedrock format
@@ -45,7 +45,7 @@ For AWS Bedrock adapter:
5. Consecutive messages with same role are merged into multi-content-block messages
6. Empty text content is converted to "(empty)"
7. system_instruction overrides context system message, with conflict warnings
8. Developer messages are promoted to system instruction or converted to user
8. Developer messages are converted to user
For OpenAI Responses adapter:
1. LLMContext messages are converted to Responses API input items
@@ -58,7 +58,7 @@ For OpenAI Responses adapter:
8. Developer messages pass through as developer role without triggering warnings
For BaseLLMAdapter helpers:
1. _extract_initial_system_or_developer: system/developer extraction and conversion logic
1. _extract_initial_system: system extraction and conversion logic
2. _resolve_system_instruction: conflict resolution between context and settings
"""
@@ -602,8 +602,8 @@ class TestGeminiGetLLMInvocationParams(unittest.TestCase):
self.assertEqual(params["system_instruction"], "You are helpful.")
self.assertEqual(len(params["messages"]), 1)
def test_initial_developer_message_promoted(self):
"""Initial developer message without system_instruction is promoted."""
def test_initial_developer_message_becomes_user(self):
"""Initial developer message without system_instruction becomes user, not system_instruction."""
messages: list[LLMStandardMessage] = [
{"role": "developer", "content": "Extra context."},
{"role": "user", "content": "Hello"},
@@ -611,7 +611,10 @@ class TestGeminiGetLLMInvocationParams(unittest.TestCase):
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(params["system_instruction"], "Extra context.")
self.assertIsNone(params["system_instruction"])
self.assertEqual(len(params["messages"]), 2)
self.assertEqual(params["messages"][0].role, "user")
self.assertEqual(params["messages"][0].parts[0].text, "Extra context.")
def test_both_system_instruction_and_system_message_warns(self):
"""system_instruction + initial system message warns and uses system_instruction."""
@@ -947,17 +950,22 @@ class TestAnthropicGetLLMInvocationParams(unittest.TestCase):
self.assertEqual(len(params["messages"]), 1)
self.assertEqual(params["messages"][0]["role"], "user")
def test_initial_developer_message_promoted(self):
"""Initial developer message without system_instruction is promoted to system."""
def test_initial_developer_message_becomes_user(self):
"""Initial developer message without system_instruction becomes user, not system."""
from anthropic import NOT_GIVEN
messages: list[LLMStandardMessage] = [
{"role": "developer", "content": "Extra context."},
{"role": "assistant", "content": "OK"},
{"role": "user", "content": "Hello"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context, enable_prompt_caching=False)
self.assertEqual(params["system"], "Extra context.")
self.assertEqual(len(params["messages"]), 1)
self.assertEqual(params["system"], NOT_GIVEN)
self.assertEqual(len(params["messages"]), 3)
self.assertEqual(params["messages"][0]["role"], "user")
self.assertEqual(params["messages"][0]["content"], "Extra context.")
def test_both_system_instruction_and_system_message_warns(self):
"""system_instruction + initial system message warns and uses system_instruction."""
@@ -1304,16 +1312,20 @@ class TestAWSBedrockGetLLMInvocationParams(unittest.TestCase):
self.assertEqual(params["system"], [{"text": "Be helpful."}])
def test_initial_developer_message_promoted(self):
"""Initial developer message without system_instruction is promoted."""
def test_initial_developer_message_becomes_user(self):
"""Initial developer message without system_instruction becomes user, not system."""
messages: list[LLMStandardMessage] = [
{"role": "developer", "content": "Extra context."},
{"role": "assistant", "content": "OK"},
{"role": "user", "content": "Hello"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(params["system"], [{"text": "Extra context."}])
self.assertIsNone(params["system"])
self.assertEqual(len(params["messages"]), 3)
self.assertEqual(params["messages"][0]["role"], "user")
self.assertEqual(params["messages"][0]["content"][0]["text"], "Extra context.")
def test_both_system_instruction_and_system_message_warns(self):
"""system_instruction + initial system message warns and uses system_instruction."""
@@ -1951,54 +1963,43 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
{"role": "system", "content": "Be helpful."},
{"role": "user", "content": "Hello"},
]
content, role = self.adapter._extract_initial_system_or_developer(
messages, system_instruction=None
)
content = self.adapter._extract_initial_system(messages, system_instruction=None)
self.assertEqual(content, "Be helpful.")
self.assertEqual(role, "system")
self.assertEqual(len(messages), 1) # popped
def test_extract_developer_without_system_instruction(self):
"""Developer message is extracted when no system_instruction."""
def test_extract_developer_not_extracted(self):
"""Developer message is not extracted by _extract_initial_system."""
messages = [
{"role": "developer", "content": "Context."},
{"role": "user", "content": "Hello"},
]
content, role = self.adapter._extract_initial_system_or_developer(
messages, system_instruction=None
)
self.assertEqual(content, "Context.")
self.assertEqual(role, "developer")
self.assertEqual(len(messages), 1)
def test_developer_with_system_instruction_converts_to_user(self):
"""Developer message with system_instruction is converted to user, not extracted."""
messages = [
{"role": "developer", "content": "Context."},
{"role": "user", "content": "Hello"},
]
content, role = self.adapter._extract_initial_system_or_developer(
messages, system_instruction="Be helpful."
)
content = self.adapter._extract_initial_system(messages, system_instruction=None)
self.assertIsNone(content)
self.assertIsNone(role)
self.assertEqual(len(messages), 2) # not popped
self.assertEqual(messages[0]["role"], "user") # converted to user
self.assertEqual(messages[0]["role"], "developer") # unchanged
def test_developer_with_system_instruction_not_extracted(self):
"""Developer message with system_instruction is not handled by _extract_initial_system."""
messages = [
{"role": "developer", "content": "Context."},
{"role": "user", "content": "Hello"},
]
content = self.adapter._extract_initial_system(messages, system_instruction="Be helpful.")
self.assertIsNone(content)
self.assertEqual(len(messages), 2) # not popped
self.assertEqual(messages[0]["role"], "developer") # unchanged by helper
def test_single_system_message_becomes_user(self):
"""Single system message is converted to user instead of extracting (empty prevention)."""
messages = [
{"role": "system", "content": "Be helpful."},
]
content, role = self.adapter._extract_initial_system_or_developer(
messages, system_instruction=None
)
content = self.adapter._extract_initial_system(messages, system_instruction=None)
self.assertIsNone(content)
self.assertIsNone(role)
self.assertEqual(len(messages), 1) # not popped
self.assertEqual(messages[0]["role"], "user")
@@ -2010,13 +2011,10 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
adapter = OpenAILLMAdapter()
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
content, role = adapter._extract_initial_system_or_developer(
messages, system_instruction="Be concise."
)
content = adapter._extract_initial_system(messages, system_instruction="Be concise.")
mock_logger.warning.assert_called_once()
self.assertIsNone(content)
self.assertIsNone(role)
self.assertEqual(messages[0]["role"], "user")
def test_non_system_message_ignored(self):
@@ -2024,29 +2022,23 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
messages = [
{"role": "user", "content": "Hello"},
]
content, role = self.adapter._extract_initial_system_or_developer(
messages, system_instruction=None
)
content = self.adapter._extract_initial_system(messages, system_instruction=None)
self.assertIsNone(content)
self.assertIsNone(role)
self.assertEqual(len(messages), 1)
def test_empty_messages(self):
"""Empty messages list returns None."""
messages = []
content, role = self.adapter._extract_initial_system_or_developer(
messages, system_instruction=None
)
content = self.adapter._extract_initial_system(messages, system_instruction=None)
self.assertIsNone(content)
self.assertIsNone(role)
def test_resolve_both_system_discard(self):
"""Resolve with discard=True: system_instruction wins, warns."""
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
result = self.adapter._resolve_system_instruction(
"from context", "system", "from settings", discard_context_system=True
"from context", "from settings", discard_context_system=True
)
mock_logger.warning.assert_called_once()
@@ -2056,7 +2048,7 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
"""Resolve with discard=False: warns but returns system_instruction."""
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
result = self.adapter._resolve_system_instruction(
"from context", "system", "from settings", discard_context_system=False
"from context", "from settings", discard_context_system=False
)
mock_logger.warning.assert_called_once()
@@ -2066,7 +2058,7 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
"""Only system_instruction: returns it, no warning."""
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
result = self.adapter._resolve_system_instruction(
None, None, "from settings", discard_context_system=True
None, "from settings", discard_context_system=True
)
mock_logger.warning.assert_not_called()
@@ -2075,7 +2067,7 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
def test_resolve_only_context_system_discard(self):
"""Only context system (discard=True): returns it."""
result = self.adapter._resolve_system_instruction(
"from context", "system", None, discard_context_system=True
"from context", None, discard_context_system=True
)
self.assertEqual(result, "from context")
@@ -2083,7 +2075,7 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
def test_resolve_only_context_system_keep(self):
"""Only context system (discard=False): returns None (already in messages)."""
result = self.adapter._resolve_system_instruction(
"from context", "system", None, discard_context_system=False
"from context", None, discard_context_system=False
)
self.assertIsNone(result)