Remove trailing system→user conversion for cross-call stability

Perplexity appears to have statefulness within a conversation, so
converting a system message to "user" in one call and then back to
"system" in the next (after more messages are appended) causes API
errors. Remove the trailing system→user conversion entirely — if the
context only has system messages, the API call will fail but the
mistake will be caught right away.
This commit is contained in:
Paul Kompfner
2026-03-12 16:04:46 -04:00
parent e69f5a76e1
commit 99f28120b7
2 changed files with 31 additions and 42 deletions

View File

@@ -80,15 +80,19 @@ class PerplexityLLMAdapter(OpenAILLMAdapter):
(e.g. a converted system→user message adjacent to an existing user
message gets merged).
3. **Ensure last message is user/tool** — If the last message is
3. **Remove trailing assistant messages** — If the last message is
"assistant", remove it. OpenAI appears to silently ignore trailing
assistant messages server-side, so removing them preserves equivalent
behavior while satisfying Perplexity's "last message must be
user/tool" constraint. If the last message is "system", convert it
to "user". A trailing system message can only occur when the context
consists entirely of system messages (possibly followed by assistant
messages that were just removed), because step 1 converts any system
message that appears after a non-system message to "user".
user/tool" constraint.
Note: we intentionally do *not* convert a trailing system message to
"user". That would make the transformation unstable across calls —
Perplexity appears to have statefulness/caching within a conversation,
so a message that was sent as "user" in one call but becomes "system"
in the next (once more messages are appended) causes errors. If the
context consists entirely of system messages, the Perplexity API call
will fail, but that mistake will be caught right away.
Args:
messages: List of message dicts with "role" and "content" keys.
@@ -138,17 +142,11 @@ class PerplexityLLMAdapter(OpenAILLMAdapter):
else:
i += 1
# Step 3: Handle trailing messages.
# Step 3: Remove trailing assistant messages.
# Perplexity requires the last message to be "user" or "tool".
if messages:
# Remove trailing assistant messages. OpenAI appears to silently
# ignore trailing assistant messages server-side, so removing them
# preserves equivalent behavior.
while messages and messages[-1].get("role") == "assistant":
messages.pop()
# If the last message is "system", convert it to "user".
if messages and messages[-1].get("role") == "system":
messages[-1]["role"] = "user"
# OpenAI appears to silently ignore trailing assistant messages
# server-side, so removing them preserves equivalent behavior.
while messages and messages[-1].get("role") == "assistant":
messages.pop()
return messages

View File

@@ -1123,8 +1123,14 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase):
self.assertEqual(params["messages"][0]["role"], "user")
self.assertEqual(params["messages"][0]["content"], "Hello")
def test_only_system_message_converted_to_user(self):
"""Test that a single system message is converted to user role."""
def test_only_system_messages_preserved(self):
"""Test that system-only contexts are left unchanged (no system→user conversion).
We intentionally do not convert trailing system messages to "user"
because that would make the transformation unstable across calls —
Perplexity has statefulness within a conversation, so a message that
was "user" in one call but becomes "system" in the next causes errors.
"""
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are a helpful assistant."},
]
@@ -1133,30 +1139,15 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase):
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["messages"]), 1)
self.assertEqual(params["messages"][0]["role"], "user")
self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.")
def test_only_system_messages_last_converted_to_user(self):
"""Test that when only system messages exist, the last one is converted to user."""
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "system", "content": "Always be polite."},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["messages"]), 2)
self.assertEqual(params["messages"][0]["role"], "system")
self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.")
self.assertEqual(params["messages"][1]["role"], "user")
self.assertEqual(params["messages"][1]["content"], "Always be polite.")
def test_trailing_assistant_removed_then_system_converted(self):
"""Test that trailing assistant is removed, exposing a system message that becomes user.
def test_system_exposed_after_trailing_assistant_removed(self):
"""Test that a system message exposed by trailing assistant removal stays system.
This exercises the ordering of step 3: strip trailing assistants first,
then convert a trailing system to user.
It's important that initial system messages are never converted to
"user", because Perplexity has statefulness within a conversation — if
a message was sent as "system" in one call and then becomes "user" in a
later call (after more messages are appended), the API rejects it.
"""
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are helpful."},
@@ -1166,9 +1157,9 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase):
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
# Trailing assistant removed → [system] system converted to user → [user]
# Trailing assistant removed → [system], system stays as-is
self.assertEqual(len(params["messages"]), 1)
self.assertEqual(params["messages"][0]["role"], "user")
self.assertEqual(params["messages"][0]["role"], "system")
self.assertEqual(params["messages"][0]["content"], "You are helpful.")
def test_consecutive_assistants_merged_then_trailing_removed(self):