Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions backend/app/services/llm/caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,9 +452,26 @@ async def call_llm(
tools_for_llm = await get_agent_tools_for_llm(agent_id) if agent_id else AGENT_TOOLS
allowed_tool_names = _allowed_tool_names(tools_for_llm)

# Convert messages to LLMMessage format
api_messages = [LLMMessage(role="system", content=static_prompt, dynamic_content=dynamic_prompt)]
# Convert messages to LLMMessage format. Some OpenAI-compatible local
# providers only allow one leading system message, so fold caller-supplied
# system instructions into the base agent context.
conversation_messages: list[dict] = []
extra_system_parts: list[str] = []
for msg in messages:
if msg.get("role") == "system":
content = msg.get("content")
if isinstance(content, str):
extra_system_parts.append(content)
elif content:
extra_system_parts.append(json.dumps(content, ensure_ascii=False))
continue
conversation_messages.append(msg)
if extra_system_parts:
extra_system = "\n\n".join(part for part in extra_system_parts if part)
dynamic_prompt = f"{dynamic_prompt}\n\n{extra_system}" if dynamic_prompt else extra_system
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve merged system prompts for all providers

When call_llm is used with Gemini or the OpenAI Responses provider and the caller supplies an extra system message (for example the websocket onboarding prompt), this moves that text into dynamic_content, but those clients only serialize msg.content for system messages (GeminiClient._build_payload and OpenAIResponsesClient._messages_to_input do not read dynamic_content). As a result the caller-supplied system instructions are silently dropped for those providers, whereas before they were sent as their own system message content.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This modification can't fix the problem in the issue.


api_messages = [LLMMessage(role="system", content=static_prompt, dynamic_content=dynamic_prompt)]
for msg in conversation_messages:
api_messages.append(LLMMessage(
role=msg.get("role", "user"),
content=msg.get("content"),
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/test_finish_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,41 @@ async def test_skip_tools_still_exposes_finish(monkeypatch):
assert tool_names == ["finish"]


@pytest.mark.asyncio
async def test_call_llm_merges_extra_system_messages(monkeypatch):
from app.services.llm import caller

fake_client = FakeStreamClient([_finish_response("Hello.")])

monkeypatch.setattr(caller, "_get_agent_config", lambda _agent_id: _async_return((1, None)))
monkeypatch.setattr(caller, "_get_user_name", lambda _user_id: _async_return("Ray"))
monkeypatch.setattr(
"app.services.agent_context.build_agent_context",
lambda *_args, **_kwargs: _async_return(("static", "dynamic")),
)
monkeypatch.setattr(caller, "create_llm_client", lambda **_kwargs: fake_client)
monkeypatch.setattr(caller, "record_token_usage", lambda *_args, **_kwargs: _async_return(None))

result = await caller.call_llm(
_model(),
[
{"role": "system", "content": "onboarding instructions"},
{"role": "user", "content": "Please begin the onboarding."},
],
"Agent",
"",
agent_id=uuid.uuid4(),
user_id=uuid.uuid4(),
skip_tools=True,
)

assert result == "Hello."
first_round_messages = fake_client.messages_seen[0]
assert [msg.role for msg in first_round_messages] == ["system", "user"]
assert first_round_messages[0].content == "static"
assert first_round_messages[0].dynamic_content == "dynamic\n\nonboarding instructions"


@pytest.mark.asyncio
async def test_execute_tool_finish_is_noop_control_signal(monkeypatch):
from app.services import agent_tools
Expand Down