fix(subagents): update SubagentConfig.system_prompt to str | None and add astream regression test

Agent-Logs-Url: https://github.com/bytedance/deer-flow/sessions/2ee03a26-e19b-4106-abc5-c76a2906383b

Co-authored-by: WillemJiang <219644+WillemJiang@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-03 15:50:03 +00:00
committed by GitHub
parent afdaf9a2ae
commit 4e1c1618ed
2 changed files with 72 additions and 1 deletions
@@ -26,7 +26,7 @@ class SubagentConfig:
name: str
description: str
system_prompt: str
system_prompt: str | None = None
tools: list[str] | None = None
disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"])
skills: list[str] | None = None
+71
View File
@@ -601,6 +601,77 @@ class TestAsyncExecutionPath:
assert result.status == SubagentStatus.COMPLETED
assert "Task" in result.result
@pytest.mark.anyio
async def test_aexecute_passes_at_most_one_system_message_to_agent(
self,
classes,
base_config,
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
"""Regression: messages sent to agent.astream must contain at most one
SystemMessage and it must be the first message.
This catches any regression where system_prompt would be re-injected
via create_agent() (e.g. system_prompt not passed as None) and appear
as a second SystemMessage, which providers like vLLM and Xinference
reject with "System message must be at the beginning."
"""
from langchain_core.messages import AIMessage, SystemMessage
SubagentExecutor = classes["SubagentExecutor"]
SubagentStatus = classes["SubagentStatus"]
# Set up a skill so both system_prompt AND skill content are present,
# maximising the chance of catching a double-SystemMessage regression.
skill_dir = tmp_path / "regression-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Skill instruction text", encoding="utf-8")
monkeypatch.setattr(
"deerflow.skills.storage.get_or_new_skill_storage",
lambda *, app_config=None: SimpleNamespace(
load_skills=lambda *, enabled_only: [
SimpleNamespace(name="regression-skill", skill_file=skill_dir / "SKILL.md")
]
),
)
captured_states: list[dict] = []
async def capturing_astream(state, **kwargs):
captured_states.append(state)
yield {"messages": [AIMessage(content="Done", id="msg-1")]}
mock_agent = MagicMock()
mock_agent.astream = capturing_astream
executor = SubagentExecutor(
config=base_config,
tools=[],
thread_id="test-thread",
)
with patch.object(executor, "_create_agent", return_value=mock_agent):
result = await executor._aexecute("Do something")
assert result.status == SubagentStatus.COMPLETED
assert len(captured_states) == 1, "astream should be called exactly once"
initial_messages = captured_states[0]["messages"]
system_messages = [m for m in initial_messages if isinstance(m, SystemMessage)]
assert len(system_messages) <= 1, (
f"Expected at most 1 SystemMessage but got {len(system_messages)}: {system_messages}"
)
if system_messages:
assert initial_messages[0] is system_messages[0], (
"SystemMessage must be the first message in the conversation"
)
# The consolidated SystemMessage must carry both the system_prompt
# and all skill content — nothing should be split across two messages.
assert base_config.system_prompt in system_messages[0].content
assert "Skill instruction text" in system_messages[0].content
# -----------------------------------------------------------------------------
# Sync Execution Path Tests