Skip to content

Commit 2eaff9c

Browse files
mattdai01daimatthclaude
authored
fix(skills): preserve cache points in system prompt during skills inj… (#2134)
Co-authored-by: Matthew Dai <daimatth@amazon.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b207e03 commit 2eaff9c

4 files changed

Lines changed: 154 additions & 23 deletions

File tree

src/strands/agent/agent.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,18 @@ def system_prompt(self, value: str | list[SystemContentBlock] | None) -> None:
428428
"""
429429
self._system_prompt, self._system_prompt_content = self._initialize_system_prompt(value)
430430

431+
@property
432+
def system_prompt_content(self) -> list[SystemContentBlock] | None:
433+
"""Get the system prompt as a list of content blocks.
434+
435+
Returns the structured content block representation, preserving cache points
436+
and other non-text blocks. Returns None if no system prompt is set.
437+
438+
Returns:
439+
The system prompt as a list of content blocks, or None if no system prompt is set.
440+
"""
441+
return list(self._system_prompt_content) if self._system_prompt_content is not None else None
442+
431443
@property
432444
def tool(self) -> _ToolCaller:
433445
"""Call tool as a function.

src/strands/vended_plugins/skills/agent_skills.py

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from ...hooks.events import BeforeInvocationEvent
1616
from ...plugins import Plugin, hook
1717
from ...tools.decorator import tool
18+
from ...types.content import SystemContentBlock
1819
from ...types.tools import ToolContext
1920
from .skill import Skill
2021

@@ -136,34 +137,51 @@ def skills(self, skill_name: str, tool_context: ToolContext) -> str: # noqa: D4
136137
def _on_before_invocation(self, event: BeforeInvocationEvent) -> None:
137138
"""Inject skill metadata into the system prompt before each invocation.
138139
139-
Removes the previously injected XML block (if any) via exact string
140-
replacement, then appends a fresh one. Uses agent state to track the
141-
injected XML per-agent, so a single plugin instance can be shared
142-
across multiple agents safely.
140+
Removes the previously injected XML block (if any) via exact match,
141+
then appends a fresh one. Uses agent state to track the injected XML
142+
per-agent, so a single plugin instance can be shared across multiple
143+
agents safely.
144+
145+
When the agent has a structured system prompt (list of SystemContentBlock),
146+
the injection is done at the block level so that cache points and other
147+
structured blocks are preserved. Otherwise falls back to string manipulation.
143148
144149
Args:
145150
event: The before-invocation event containing the agent reference.
146151
"""
147152
agent = event.agent
148153

149-
current_prompt = agent.system_prompt or ""
150-
151-
# Remove the previously injected XML block by exact match
152154
state_data = agent.state.get(self._state_key)
153155
last_injected_xml = state_data.get("last_injected_xml") if isinstance(state_data, dict) else None
154-
if last_injected_xml is not None:
155-
if last_injected_xml in current_prompt:
156-
current_prompt = current_prompt.replace(last_injected_xml, "")
157-
else:
158-
logger.warning("unable to find previously injected skills XML in system prompt, re-appending")
159156

160157
skills_xml = self._generate_skills_xml()
161-
injection = f"\n\n{skills_xml}"
162-
new_prompt = f"{current_prompt}{injection}" if current_prompt else skills_xml
163-
164-
new_injected_xml = injection if current_prompt else skills_xml
165-
self._set_state_field(agent, "last_injected_xml", new_injected_xml)
166-
agent.system_prompt = new_prompt
158+
content = agent.system_prompt_content
159+
160+
if content is not None:
161+
# Content-block path: preserve cache points and other structured blocks
162+
blocks: list[SystemContentBlock] = list(content)
163+
if last_injected_xml is not None:
164+
injected_block: SystemContentBlock = {"text": last_injected_xml}
165+
if injected_block in blocks:
166+
blocks.remove(injected_block)
167+
else:
168+
logger.warning("unable to find previously injected skills XML in system prompt, re-appending")
169+
blocks.append({"text": skills_xml})
170+
self._set_state_field(agent, "last_injected_xml", skills_xml)
171+
agent.system_prompt = blocks
172+
else:
173+
# String path: legacy behaviour for plain-string system prompts
174+
current_prompt = agent.system_prompt or ""
175+
if last_injected_xml is not None:
176+
if last_injected_xml in current_prompt:
177+
current_prompt = current_prompt.replace(last_injected_xml, "")
178+
else:
179+
logger.warning("unable to find previously injected skills XML in system prompt, re-appending")
180+
injection = f"\n\n{skills_xml}"
181+
new_prompt = f"{current_prompt}{injection}" if current_prompt else skills_xml
182+
new_injected_xml = injection if current_prompt else skills_xml
183+
self._set_state_field(agent, "last_injected_xml", new_injected_xml)
184+
agent.system_prompt = new_prompt
167185

168186
def get_available_skills(self) -> list[Skill]:
169187
"""Get the list of available skills.

tests/strands/agent/test_agent.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,6 +1166,33 @@ def test_system_prompt_setter_none():
11661166
assert agent._system_prompt_content is None
11671167

11681168

1169+
def test_system_prompt_content_string():
1170+
"""Test that system_prompt_content returns content blocks for string prompt."""
1171+
agent = Agent(system_prompt="hello")
1172+
assert agent.system_prompt_content == [{"text": "hello"}]
1173+
1174+
1175+
def test_system_prompt_content_structured():
1176+
"""Test that system_prompt_content returns structured blocks with cache points."""
1177+
blocks = [{"text": "You are helpful"}, {"cachePoint": {"type": "default"}}]
1178+
agent = Agent(system_prompt=blocks)
1179+
assert agent.system_prompt_content == blocks
1180+
1181+
1182+
def test_system_prompt_content_none():
1183+
"""Test that system_prompt_content returns None when no prompt is set."""
1184+
agent = Agent(system_prompt=None)
1185+
assert agent.system_prompt_content is None
1186+
1187+
1188+
def test_system_prompt_content_returns_copy():
1189+
"""Test that system_prompt_content returns a defensive copy."""
1190+
agent = Agent(system_prompt="hello")
1191+
content = agent.system_prompt_content
1192+
content.append({"text": "injected"})
1193+
assert agent.system_prompt_content == [{"text": "hello"}]
1194+
1195+
11691196
@pytest.mark.asyncio
11701197
async def test_stream_async_passes_invocation_state(agent, mock_model, mock_event_loop_cycle, agenerator, alist):
11711198
mock_model.mock_stream.side_effect = [

tests/strands/vended_plugins/skills/test_agent_skills.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,12 @@ def _mock_agent():
3232
agent._system_prompt = "You are an agent."
3333
agent._system_prompt_content = [{"text": "You are an agent."}]
3434

35-
# Make system_prompt property behave like the real Agent
35+
# Make system_prompt and system_prompt_content properties behave like the real Agent
3636
type(agent).system_prompt = property(
3737
lambda self: self._system_prompt,
3838
lambda self, value: _set_system_prompt(self, value),
3939
)
40+
type(agent).system_prompt_content = property(lambda self: self._system_prompt_content)
4041

4142
agent.hooks = HookRegistry()
4243
agent.add_hook = MagicMock(
@@ -59,11 +60,15 @@ def _mock_tool_context(agent: MagicMock) -> ToolContext:
5960
return ToolContext(tool_use=tool_use, agent=agent, invocation_state={"agent": agent})
6061

6162

62-
def _set_system_prompt(agent: MagicMock, value: str | None) -> None:
63+
def _set_system_prompt(agent: MagicMock, value: str | list | None) -> None:
6364
"""Simulate the Agent.system_prompt setter."""
6465
if isinstance(value, str):
6566
agent._system_prompt = value
6667
agent._system_prompt_content = [{"text": value}]
68+
elif isinstance(value, list):
69+
text_parts = [block["text"] for block in value if "text" in block]
70+
agent._system_prompt = "\n".join(text_parts) if text_parts else None
71+
agent._system_prompt_content = value
6772
elif value is None:
6873
agent._system_prompt = None
6974
agent._system_prompt_content = None
@@ -417,9 +422,41 @@ def test_uses_public_system_prompt_setter(self):
417422
event = BeforeInvocationEvent(agent=agent)
418423
plugin._on_before_invocation(event)
419424

420-
# The public setter should have been used, so _system_prompt_content
421-
# should be consistent with _system_prompt
422-
assert agent._system_prompt_content == [{"text": agent._system_prompt}]
425+
# The public setter should have been used via the content-block path:
426+
# original block is preserved and the skills XML is appended as a new block.
427+
assert len(agent.system_prompt_content) == 2
428+
assert agent.system_prompt_content[0] == {"text": "Original."}
429+
assert "<available_skills>" in agent.system_prompt_content[1]["text"]
430+
431+
def test_preserves_cache_points_in_system_prompt(self):
432+
"""Test that cachePoint blocks in the system prompt are preserved after injection."""
433+
plugin = AgentSkills(skills=[_make_skill()])
434+
agent = _mock_agent()
435+
agent._system_prompt = "Base instructions."
436+
agent._system_prompt_content = [
437+
{"text": "Base instructions."},
438+
{"cachePoint": {"type": "default"}},
439+
]
440+
441+
expected_skills_xml = plugin._generate_skills_xml()
442+
443+
event = BeforeInvocationEvent(agent=agent)
444+
plugin._on_before_invocation(event)
445+
446+
# Exact block structure: original text, cachePoint, skills XML
447+
assert agent.system_prompt_content == [
448+
{"text": "Base instructions."},
449+
{"cachePoint": {"type": "default"}},
450+
{"text": expected_skills_xml},
451+
]
452+
453+
# Repeated invocation: identical result, no accumulation
454+
plugin._on_before_invocation(event)
455+
assert agent.system_prompt_content == [
456+
{"text": "Base instructions."},
457+
{"cachePoint": {"type": "default"}},
458+
{"text": expected_skills_xml},
459+
]
423460

424461
def test_warns_when_previous_xml_not_found(self, caplog):
425462
"""Test that a warning is logged when the previously injected XML is missing from the prompt."""
@@ -441,6 +478,43 @@ def test_warns_when_previous_xml_not_found(self, caplog):
441478
assert "<available_skills>" in agent.system_prompt
442479

443480

481+
class TestStringPathInjection:
482+
"""Tests for the string-path branch of _on_before_invocation (system_prompt_content is None)."""
483+
484+
def test_string_path_replaces_previous_xml(self):
485+
"""Test that old injected XML is replaced when found in the string prompt."""
486+
plugin = AgentSkills(skills=[_make_skill()])
487+
agent = _mock_agent()
488+
489+
old_xml = "\n\n<old>xml</old>"
490+
agent._system_prompt = f"Base prompt.{old_xml}"
491+
agent._system_prompt_content = None
492+
agent.state.set(plugin._state_key, {"last_injected_xml": old_xml})
493+
494+
event = BeforeInvocationEvent(agent=agent)
495+
plugin._on_before_invocation(event)
496+
497+
assert "<old>xml</old>" not in agent.system_prompt
498+
assert "<available_skills>" in agent.system_prompt
499+
assert agent.system_prompt.startswith("Base prompt.")
500+
501+
def test_string_path_warns_when_previous_xml_not_found(self, caplog):
502+
"""Test that a warning is logged when old XML is missing from the string prompt."""
503+
plugin = AgentSkills(skills=[_make_skill()])
504+
agent = _mock_agent()
505+
506+
agent._system_prompt = "Totally new prompt."
507+
agent._system_prompt_content = None
508+
agent.state.set(plugin._state_key, {"last_injected_xml": "\n\n<old>xml</old>"})
509+
510+
event = BeforeInvocationEvent(agent=agent)
511+
with caplog.at_level(logging.WARNING):
512+
plugin._on_before_invocation(event)
513+
514+
assert "unable to find previously injected skills XML in system prompt" in caplog.text
515+
assert "<available_skills>" in agent.system_prompt
516+
517+
444518
class TestSkillsXmlGeneration:
445519
"""Tests for _generate_skills_xml."""
446520

0 commit comments

Comments
 (0)