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 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 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 # Fallback to return the same tools in case they are not in a standard format
return tools return tools
def _extract_initial_system_or_developer( def _extract_initial_system(
self, self,
messages: list, messages: list,
*, *,
system_instruction: Optional[str], system_instruction: Optional[str] = None,
) -> Tuple[Optional[str], Optional[str]]: ) -> Optional[str]:
"""Extract an initial system/developer message for use as a system instruction. """Extract an initial ``"system"`` message for use as a system instruction.
Only useful for services that expect the system instruction as a Only useful for services that expect the system instruction as a
separate parameter, not inline in conversation history (today, all 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: Checks ``messages[0]``. If the role is ``"system"``, pops and returns
its content. If extracting would leave the messages list empty
- ``"system"`` role: assumed to be intended as the system instruction. (``len(messages) == 1``), the message is converted to ``"user"``
Extract (pop from messages). role instead of being extracted, to prevent sending an empty
- ``"developer"`` role **without** ``system_instruction``: also assumed conversation history to providers that require at least one
to be intended as the system instruction. Extract (pop). non-system message.
- ``"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.
Args: Args:
messages: Message list in standard format (mutated in-place). messages: Message list in standard format (mutated in-place).
system_instruction: The system instruction from service settings system_instruction: The system instruction from service settings
or ``run_inference``, used to decide whether to extract a or ``run_inference``. Only used to decide whether to warn
``"developer"`` message. about a conflict in the single-message case.
Returns: Returns:
``(extracted_content, original_role)`` where *original_role* is The extracted system message content, or ``None`` if nothing
``"system"`` or ``"developer"``, or ``(None, None)`` if nothing
was extracted. was extracted.
""" """
if not messages: if not messages:
return None, None return None
role = messages[0].get("role") if messages[0].get("role") != "system":
if role not in ("system", "developer"): return None
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
# Would extracting empty the list? Convert to "user" instead. # Would extracting empty the list? Convert to "user" instead.
if len(messages) == 1: if len(messages) == 1:
if role == "system" and system_instruction: if system_instruction:
if not self._warned_system_instruction: if not self._warned_system_instruction:
self._warned_system_instruction = True self._warned_system_instruction = True
logger.warning( logger.warning(
@@ -198,7 +184,7 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]):
" history." " history."
) )
messages[0]["role"] = "user" messages[0]["role"] = "user"
return None, None return None
# Extract # Extract
content = messages[0].get("content", "") 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" part.get("text", "") for part in content if part.get("type") == "text"
) )
messages.pop(0) messages.pop(0)
return content, role return content
def _resolve_system_instruction( def _resolve_system_instruction(
self, self,
initial_context_message: Optional[str], system_from_context: Optional[str],
initial_context_message_role: Optional[str],
system_instruction: Optional[str], system_instruction: Optional[str],
*, *,
discard_context_system: bool, discard_context_system: bool,
) -> Optional[str]: ) -> Optional[str]:
"""Resolve conflict between ``system_instruction`` and an initial context message. """Resolve conflict between ``system_instruction`` and an extracted context system 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.
Args: Args:
initial_context_message: Content extracted from ``messages[0]`` system_from_context: Content extracted from an initial ``"system"``
by :meth:`_extract_initial_system_or_developer`, or detected message by :meth:`_extract_initial_system`, or detected
inline (OpenAI adapters). 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. system_instruction: From service settings or ``run_inference`` param.
discard_context_system: If ``True`` (non-OpenAI adapters), the discard_context_system: If ``True`` (non-OpenAI adapters), the
context system message is discarded when ``system_instruction`` 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 The effective system instruction to use, or ``None`` if the system
instruction is already represented in the messages (OpenAI path). instruction is already represented in the messages (OpenAI path).
""" """
both_present = initial_context_message and system_instruction if system_from_context and system_instruction:
from_system_role = initial_context_message_role == "system"
if both_present and from_system_role:
if not self._warned_system_instruction: if not self._warned_system_instruction:
self._warned_system_instruction = True self._warned_system_instruction = True
if discard_context_system: if discard_context_system:
@@ -257,15 +233,11 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]):
) )
if system_instruction: if system_instruction:
if discard_context_system: return system_instruction
return system_instruction
else:
# OpenAI path: caller prepends; return the instruction for prepending
return system_instruction
if initial_context_message: if system_from_context:
if discard_context_system: if discard_context_system:
return initial_context_message return system_from_context
else: else:
# Content is already in messages; nothing to prepend # Content is already in messages; nothing to prepend
return None return None

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,7 +68,6 @@ class OpenAIResponsesLLMAdapter(BaseLLMAdapter[OpenAIResponsesLLMInvocationParam
if first_msg and first_msg.get("role") == "system": if first_msg and first_msg.get("role") == "system":
self._resolve_system_instruction( self._resolve_system_instruction(
first_msg.get("content", ""), first_msg.get("content", ""),
"system",
system_instruction, system_instruction,
discard_context_system=False, 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 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 6. Multiple system instructions: first extracted, later ones converted to user messages
7. system_instruction overrides context system message, with conflict warnings 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: For Anthropic adapter:
1. LLMStandardMessage objects are converted to Anthropic MessageParam format 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 5. Consecutive messages with same role are merged into multi-content-block messages
6. Empty text content is converted to "(empty)" 6. Empty text content is converted to "(empty)"
7. system_instruction overrides context system message, with conflict warnings 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: For AWS Bedrock adapter:
1. LLMStandardMessage objects are converted to AWS Bedrock format 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 5. Consecutive messages with same role are merged into multi-content-block messages
6. Empty text content is converted to "(empty)" 6. Empty text content is converted to "(empty)"
7. system_instruction overrides context system message, with conflict warnings 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: For OpenAI Responses adapter:
1. LLMContext messages are converted to Responses API input items 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 8. Developer messages pass through as developer role without triggering warnings
For BaseLLMAdapter helpers: 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 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(params["system_instruction"], "You are helpful.")
self.assertEqual(len(params["messages"]), 1) self.assertEqual(len(params["messages"]), 1)
def test_initial_developer_message_promoted(self): def test_initial_developer_message_becomes_user(self):
"""Initial developer message without system_instruction is promoted.""" """Initial developer message without system_instruction becomes user, not system_instruction."""
messages: list[LLMStandardMessage] = [ messages: list[LLMStandardMessage] = [
{"role": "developer", "content": "Extra context."}, {"role": "developer", "content": "Extra context."},
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},
@@ -611,7 +611,10 @@ class TestGeminiGetLLMInvocationParams(unittest.TestCase):
context = LLMContext(messages=messages) context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context) 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): def test_both_system_instruction_and_system_message_warns(self):
"""system_instruction + initial system message warns and uses system_instruction.""" """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(len(params["messages"]), 1)
self.assertEqual(params["messages"][0]["role"], "user") self.assertEqual(params["messages"][0]["role"], "user")
def test_initial_developer_message_promoted(self): def test_initial_developer_message_becomes_user(self):
"""Initial developer message without system_instruction is promoted to system.""" """Initial developer message without system_instruction becomes user, not system."""
from anthropic import NOT_GIVEN
messages: list[LLMStandardMessage] = [ messages: list[LLMStandardMessage] = [
{"role": "developer", "content": "Extra context."}, {"role": "developer", "content": "Extra context."},
{"role": "assistant", "content": "OK"},
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},
] ]
context = LLMContext(messages=messages) context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context, enable_prompt_caching=False) params = self.adapter.get_llm_invocation_params(context, enable_prompt_caching=False)
self.assertEqual(params["system"], "Extra context.") self.assertEqual(params["system"], NOT_GIVEN)
self.assertEqual(len(params["messages"]), 1) 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): def test_both_system_instruction_and_system_message_warns(self):
"""system_instruction + initial system message warns and uses system_instruction.""" """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."}]) self.assertEqual(params["system"], [{"text": "Be helpful."}])
def test_initial_developer_message_promoted(self): def test_initial_developer_message_becomes_user(self):
"""Initial developer message without system_instruction is promoted.""" """Initial developer message without system_instruction becomes user, not system."""
messages: list[LLMStandardMessage] = [ messages: list[LLMStandardMessage] = [
{"role": "developer", "content": "Extra context."}, {"role": "developer", "content": "Extra context."},
{"role": "assistant", "content": "OK"},
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},
] ]
context = LLMContext(messages=messages) context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context) 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): def test_both_system_instruction_and_system_message_warns(self):
"""system_instruction + initial system message warns and uses system_instruction.""" """system_instruction + initial system message warns and uses system_instruction."""
@@ -1951,54 +1963,43 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
{"role": "system", "content": "Be helpful."}, {"role": "system", "content": "Be helpful."},
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},
] ]
content, role = self.adapter._extract_initial_system_or_developer( content = self.adapter._extract_initial_system(messages, system_instruction=None)
messages, system_instruction=None
)
self.assertEqual(content, "Be helpful.") self.assertEqual(content, "Be helpful.")
self.assertEqual(role, "system")
self.assertEqual(len(messages), 1) # popped self.assertEqual(len(messages), 1) # popped
def test_extract_developer_without_system_instruction(self): def test_extract_developer_not_extracted(self):
"""Developer message is extracted when no system_instruction.""" """Developer message is not extracted by _extract_initial_system."""
messages = [ messages = [
{"role": "developer", "content": "Context."}, {"role": "developer", "content": "Context."},
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},
] ]
content, role = self.adapter._extract_initial_system_or_developer( content = self.adapter._extract_initial_system(messages, system_instruction=None)
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."
)
self.assertIsNone(content) self.assertIsNone(content)
self.assertIsNone(role)
self.assertEqual(len(messages), 2) # not popped 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): def test_single_system_message_becomes_user(self):
"""Single system message is converted to user instead of extracting (empty prevention).""" """Single system message is converted to user instead of extracting (empty prevention)."""
messages = [ messages = [
{"role": "system", "content": "Be helpful."}, {"role": "system", "content": "Be helpful."},
] ]
content, role = self.adapter._extract_initial_system_or_developer( content = self.adapter._extract_initial_system(messages, system_instruction=None)
messages, system_instruction=None
)
self.assertIsNone(content) self.assertIsNone(content)
self.assertIsNone(role)
self.assertEqual(len(messages), 1) # not popped self.assertEqual(len(messages), 1) # not popped
self.assertEqual(messages[0]["role"], "user") self.assertEqual(messages[0]["role"], "user")
@@ -2010,13 +2011,10 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
adapter = OpenAILLMAdapter() adapter = OpenAILLMAdapter()
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger: with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
content, role = adapter._extract_initial_system_or_developer( content = adapter._extract_initial_system(messages, system_instruction="Be concise.")
messages, system_instruction="Be concise."
)
mock_logger.warning.assert_called_once() mock_logger.warning.assert_called_once()
self.assertIsNone(content) self.assertIsNone(content)
self.assertIsNone(role)
self.assertEqual(messages[0]["role"], "user") self.assertEqual(messages[0]["role"], "user")
def test_non_system_message_ignored(self): def test_non_system_message_ignored(self):
@@ -2024,29 +2022,23 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
messages = [ messages = [
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},
] ]
content, role = self.adapter._extract_initial_system_or_developer( content = self.adapter._extract_initial_system(messages, system_instruction=None)
messages, system_instruction=None
)
self.assertIsNone(content) self.assertIsNone(content)
self.assertIsNone(role)
self.assertEqual(len(messages), 1) self.assertEqual(len(messages), 1)
def test_empty_messages(self): def test_empty_messages(self):
"""Empty messages list returns None.""" """Empty messages list returns None."""
messages = [] messages = []
content, role = self.adapter._extract_initial_system_or_developer( content = self.adapter._extract_initial_system(messages, system_instruction=None)
messages, system_instruction=None
)
self.assertIsNone(content) self.assertIsNone(content)
self.assertIsNone(role)
def test_resolve_both_system_discard(self): def test_resolve_both_system_discard(self):
"""Resolve with discard=True: system_instruction wins, warns.""" """Resolve with discard=True: system_instruction wins, warns."""
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger: with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
result = self.adapter._resolve_system_instruction( 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() mock_logger.warning.assert_called_once()
@@ -2056,7 +2048,7 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
"""Resolve with discard=False: warns but returns system_instruction.""" """Resolve with discard=False: warns but returns system_instruction."""
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger: with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
result = self.adapter._resolve_system_instruction( 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() mock_logger.warning.assert_called_once()
@@ -2066,7 +2058,7 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
"""Only system_instruction: returns it, no warning.""" """Only system_instruction: returns it, no warning."""
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger: with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
result = self.adapter._resolve_system_instruction( 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() mock_logger.warning.assert_not_called()
@@ -2075,7 +2067,7 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
def test_resolve_only_context_system_discard(self): def test_resolve_only_context_system_discard(self):
"""Only context system (discard=True): returns it.""" """Only context system (discard=True): returns it."""
result = self.adapter._resolve_system_instruction( 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") self.assertEqual(result, "from context")
@@ -2083,7 +2075,7 @@ class TestBaseLLMAdapterHelpers(unittest.TestCase):
def test_resolve_only_context_system_keep(self): def test_resolve_only_context_system_keep(self):
"""Only context system (discard=False): returns None (already in messages).""" """Only context system (discard=False): returns None (already in messages)."""
result = self.adapter._resolve_system_instruction( result = self.adapter._resolve_system_instruction(
"from context", "system", None, discard_context_system=False "from context", None, discard_context_system=False
) )
self.assertIsNone(result) self.assertIsNone(result)