fix: inject prompt template guardrails into plan agent system message (#79)

* fix: inject prompt template guardrails into plan agent system message

Safety/behavioral templates were silently bypassed by the plan agent,
which only used _PLAN_IDENTITY. Now _plan_system_content() prepends
_template_content (when present) so admin-configured guardrails apply
to both _exec_plan and _refine_plan, matching the task agent pattern.

* fix: address Copilot review — log truncation, comment clarity, test robustness

- Log warning on template truncation in _plan_system_content() for
  consistency with _init_system_messages()
- Clarify comment that prior plan pairs (not general history) are forwarded
- Use ChatSession._PLAN_IDENTITY for index assertions instead of substring
This commit is contained in:
Patrick Buckley
2026-03-15 14:24:10 -07:00
committed by GitHub
parent 9b3b1c1ddd
commit 730f5704ff
2 changed files with 58 additions and 4 deletions
+43
View File
@@ -338,6 +338,28 @@ class TestPlanExec:
# Last user message in second call is the coaching message
assert "did not follow" in captured_messages[1][-1]["content"]
def test_plan_includes_template_content(self, tmp_db, tmp_path, monkeypatch):
"""Plan agent system message includes template guardrails."""
monkeypatch.chdir(tmp_path)
session = _make_session()
session._template_content = "SAFETY: Do not produce harmful plans."
_, _, messages = self._run_plan(session, "build something")
sys_content = messages[0]["content"]
assert "SAFETY: Do not produce harmful plans." in sys_content
assert ChatSession._PLAN_IDENTITY in sys_content
# Template appears before plan identity
tpl_pos = sys_content.index("SAFETY:")
identity_pos = sys_content.index(ChatSession._PLAN_IDENTITY)
assert tpl_pos < identity_pos
def test_plan_no_template_is_identity_only(self, tmp_db, tmp_path, monkeypatch):
"""Without templates, plan system message is exactly _PLAN_IDENTITY."""
monkeypatch.chdir(tmp_path)
session = _make_session()
assert session._template_content is None
_, _, messages = self._run_plan(session, "build something")
assert messages[0]["content"] == ChatSession._PLAN_IDENTITY
# ---------------------------------------------------------------------------
# Plan validation
@@ -557,6 +579,27 @@ class TestPlanRefinement:
assert msgs[3]["role"] == "user"
assert "add tests too" in msgs[3]["content"]
def test_refine_plan_includes_template_content(self, tmp_db, tmp_path, monkeypatch):
"""_refine_plan system message includes template guardrails."""
monkeypatch.chdir(tmp_path)
session = _make_session()
session._template_content = "SAFETY: guardrails here"
captured = {}
def fake_run_agent(messages, **kwargs):
captured["messages"] = list(messages)
return self.GOOD_PLAN
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
session._refine_plan(self.GOOD_PLAN, "add auth", "add tests too")
sys_content = captured["messages"][0]["content"]
assert "SAFETY: guardrails here" in sys_content
assert ChatSession._PLAN_IDENTITY in sys_content
tpl_pos = sys_content.index("SAFETY:")
identity_pos = sys_content.index(ChatSession._PLAN_IDENTITY)
assert tpl_pos < identity_pos
# ---------------------------------------------------------------------------
# Vision / image support
+15 -4
View File
@@ -3549,6 +3549,16 @@ class ChatSession:
"and functions in every step."
)
def _plan_system_content(self) -> str:
"""Plan agent system message: template guardrails + plan identity."""
if not self._template_content:
return self._PLAN_IDENTITY
tpl = self._template_content
if len(tpl) > _MAX_TEMPLATE_CONTENT:
log.warning("template_content.truncated", length=len(tpl), agent="plan")
tpl = tpl[:_MAX_TEMPLATE_CONTENT]
return tpl + "\n\n" + self._PLAN_IDENTITY
_MIN_PLAN_LENGTH = 100
_PLAN_REQUIRED_SECTIONS = ("## goal", "## current state", "## plan", "## risks")
_MIN_PLAN_SECTIONS = 2
@@ -3626,10 +3636,11 @@ class ChatSession:
prior_plan_msgs = [msg, self.messages[j]]
break
# Plan agent gets its own identity only — no main session system
# prompt or conversation history. It's an autonomous sub-agent.
# Plan agent gets template guardrails + its own identity — no tool
# patterns, MCP resources, or general conversation history (only
# prior plan tool_call/result pairs are forwarded for refinement).
agent_messages: list[dict[str, Any]] = [
{"role": "system", "content": self._PLAN_IDENTITY},
{"role": "system", "content": self._plan_system_content()},
]
agent_messages.extend(prior_plan_msgs)
agent_messages.append({"role": "user", "content": prompt})
@@ -3700,7 +3711,7 @@ class ChatSession:
"""Re-run the plan agent incorporating user feedback."""
tc_id = f"plan_refine_{uuid.uuid4().hex[:8]}"
agent_messages: list[dict[str, Any]] = [
{"role": "system", "content": self._PLAN_IDENTITY},
{"role": "system", "content": self._plan_system_content()},
{
"role": "assistant",
"content": None,