fix(agents): strip a leading BOM from SOUL.md before prompt injection (#7061)

Co-authored-by: Jay0130-a <Jay0130-a@users.noreply.github.com>
This commit is contained in:
Jay0130-a 2026-10-18 21:51:39 +08:00 committed by GitHub
parent d1d0958bea
commit dfb32ef52b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 41 additions and 2 deletions

View File

@ -377,7 +377,11 @@ def load_agent_soul(agent_name: str | None, *, user_id: str | None = None) -> st
soul_path = get_paths().base_dir / SOUL_FILENAME
if not soul_path.exists():
return None
content = soul_path.read_text(encoding="utf-8").strip()
# ``utf-8-sig`` drops the BOM a Windows editor (Notepad, PowerShell 5.1)
# prepends. ``str.strip()`` does not remove U+FEFF, so reading this as
# plain ``utf-8`` would make the mark the first character of the text
# injected into the lead agent's system prompt.
content = soul_path.read_text(encoding="utf-8-sig").strip()
return content or None
from deerflow.persistence.agents import get_agent_store

View File

@ -83,7 +83,11 @@ class FileAgentStore(AgentStore):
break
if not soul_path.exists():
return None
content = soul_path.read_text(encoding="utf-8").strip()
# Match the default-agent read in :func:`deerflow.config.agents_config.
# load_agent_soul`: ``utf-8-sig`` drops the BOM a Windows editor writes,
# which ``str.strip()`` would otherwise leave as the leading character of
# the ``<soul>`` block.
content = soul_path.read_text(encoding="utf-8-sig").strip()
return content or None
def list(self, *, user_id: str | None = None) -> list[AgentConfig]:

View File

@ -323,6 +323,37 @@ class TestLoadAgentSoul:
assert soul == expected_soul
def test_strips_a_leading_bom_from_named_agent_soul(self, tmp_path):
"""A SOUL.md saved by a Windows editor keeps a UTF-8 BOM.
``str.strip()`` does not remove U+FEFF, so without normalization the mark
becomes the first character of the text injected into the lead agent's
``<soul>`` block. SOUL.md is edited outside DeerFlow (Notepad, PowerShell
5.1), which is exactly where a BOM comes from.
"""
expected_soul = "You are a specialized code review expert."
_write_agent(tmp_path, "code-reviewer", {"name": "code-reviewer"}, soul="\ufeff" + expected_soul)
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import AgentConfig, load_agent_soul
cfg = AgentConfig(name="code-reviewer")
soul = load_agent_soul(cfg.name)
assert soul == expected_soul
def test_strips_a_leading_bom_from_the_default_agent_soul(self, tmp_path):
"""The default agent reads ``{base_dir}/SOUL.md`` directly — a second read site."""
expected_soul = "You are the default agent."
(tmp_path / "SOUL.md").write_text("\ufeff" + expected_soul, encoding="utf-8")
with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
from deerflow.config.agents_config import load_agent_soul
soul = load_agent_soul(None)
assert soul == expected_soul
def test_missing_soul_file_returns_none(self, tmp_path):
agent_dir = tmp_path / "agents" / "no-soul"
agent_dir.mkdir(parents=True)