Skip to content

Commit 3bf8fd5

Browse files
authored
Add exclude_dynamic_sections to SystemPromptPreset for cross-user caching (#797)
## Summary Adds `exclude_dynamic_sections` to `SystemPromptPreset`, bringing the Python SDK to parity with the TypeScript SDK option of the same name. When set, the Claude Code CLI strips per-user dynamic sections (working directory, auto-memory, git status) from the preset system prompt and re-injects them into the first user message instead. This makes the system prompt byte-identical across users with different `cwd` values, so the prompt-caching prefix can hit cross-user — useful for multi-user fleets that share the same preset + `append` configuration. ## Usage ```python from claude_agent_sdk import ClaudeAgentOptions, query options = ClaudeAgentOptions( system_prompt={ "type": "preset", "preset": "claude_code", "append": "...your shared domain instructions...", "exclude_dynamic_sections": True, }, ) ``` ## Tradeoffs - Working-directory, memory-path, and git-status context appear in a user message instead of the system prompt (marginally less authoritative for steering). - The first user message becomes slightly larger. - No effect when `system_prompt` is a plain string. ## Compatibility The option is sent via the SDK's `initialize` control message. Older Claude Code CLI versions silently ignore unknown initialize fields, so this is safe to set unconditionally — it becomes effective once the bundled CLI supports it. Closes #784
1 parent 83c1e0a commit 3bf8fd5

File tree

6 files changed

+89
-0
lines changed

6 files changed

+89
-0
lines changed

src/claude_agent_sdk/_internal/client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ async def process_query(
9191
if isinstance(config, dict) and config.get("type") == "sdk":
9292
sdk_mcp_servers[name] = config["instance"] # type: ignore[typeddict-item]
9393

94+
# Extract exclude_dynamic_sections from preset system prompt for the
95+
# initialize request (older CLIs ignore unknown initialize fields).
96+
exclude_dynamic_sections: bool | None = None
97+
sp = configured_options.system_prompt
98+
if isinstance(sp, dict) and sp.get("type") == "preset":
99+
eds = sp.get("exclude_dynamic_sections")
100+
if isinstance(eds, bool):
101+
exclude_dynamic_sections = eds
102+
94103
# Convert agents to dict format for initialize request
95104
agents_dict = None
96105
if configured_options.agents:
@@ -118,6 +127,7 @@ async def process_query(
118127
sdk_mcp_servers=sdk_mcp_servers,
119128
initialize_timeout=initialize_timeout,
120129
agents=agents_dict,
130+
exclude_dynamic_sections=exclude_dynamic_sections,
121131
)
122132

123133
try:

src/claude_agent_sdk/_internal/query.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def __init__(
7676
sdk_mcp_servers: dict[str, "McpServer"] | None = None,
7777
initialize_timeout: float = 60.0,
7878
agents: dict[str, dict[str, Any]] | None = None,
79+
exclude_dynamic_sections: bool | None = None,
7980
):
8081
"""Initialize Query with transport and callbacks.
8182
@@ -87,6 +88,8 @@ def __init__(
8788
sdk_mcp_servers: Optional SDK MCP server instances
8889
initialize_timeout: Timeout in seconds for the initialize request
8990
agents: Optional agent definitions to send via initialize
91+
exclude_dynamic_sections: Optional preset-prompt flag to send via
92+
initialize (see ``SystemPromptPreset``)
9093
"""
9194
self._initialize_timeout = initialize_timeout
9295
self.transport = transport
@@ -95,6 +98,7 @@ def __init__(
9598
self.hooks = hooks or {}
9699
self.sdk_mcp_servers = sdk_mcp_servers or {}
97100
self._agents = agents
101+
self._exclude_dynamic_sections = exclude_dynamic_sections
98102

99103
# Control protocol state
100104
self.pending_control_responses: dict[str, anyio.Event] = {}
@@ -154,6 +158,8 @@ async def initialize(self) -> dict[str, Any] | None:
154158
}
155159
if self._agents:
156160
request["agents"] = self._agents
161+
if self._exclude_dynamic_sections is not None:
162+
request["excludeDynamicSections"] = self._exclude_dynamic_sections
157163

158164
# Use longer timeout for initialize since MCP servers may take time to start
159165
response = await self._send_control_request(

src/claude_agent_sdk/client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,15 @@ async def _empty_stream() -> AsyncIterator[dict[str, Any]]:
157157
)
158158
initialize_timeout = max(initialize_timeout_ms / 1000.0, 60.0)
159159

160+
# Extract exclude_dynamic_sections from preset system prompt for the
161+
# initialize request (older CLIs ignore unknown initialize fields).
162+
exclude_dynamic_sections: bool | None = None
163+
sp = self.options.system_prompt
164+
if isinstance(sp, dict) and sp.get("type") == "preset":
165+
eds = sp.get("exclude_dynamic_sections")
166+
if isinstance(eds, bool):
167+
exclude_dynamic_sections = eds
168+
160169
# Convert agents to dict format for initialize request
161170
agents_dict: dict[str, dict[str, Any]] | None = None
162171
if self.options.agents:
@@ -176,6 +185,7 @@ async def _empty_stream() -> AsyncIterator[dict[str, Any]]:
176185
sdk_mcp_servers=sdk_mcp_servers,
177186
initialize_timeout=initialize_timeout,
178187
agents=agents_dict,
188+
exclude_dynamic_sections=exclude_dynamic_sections,
179189
)
180190

181191
# Start reading messages and initialize

src/claude_agent_sdk/types.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ class SystemPromptPreset(TypedDict):
3838
type: Literal["preset"]
3939
preset: Literal["claude_code"]
4040
append: NotRequired[str]
41+
exclude_dynamic_sections: NotRequired[bool]
42+
"""Strip per-user dynamic sections (working directory, auto-memory, git
43+
status) from the system prompt so it stays static and cacheable across
44+
users. The stripped content is re-injected into the first user message
45+
so the model still has access to it.
46+
47+
Use this when many users share the same preset system prompt and you
48+
want the prompt-caching prefix to hit cross-user.
49+
50+
Requires a Claude Code CLI version that supports this option; older
51+
CLIs silently ignore it.
52+
"""
4153

4254

4355
class SystemPromptFile(TypedDict):

tests/test_query.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,40 @@
2525
from claude_agent_sdk.types import HookMatcher
2626

2727

28+
def _capture_initialize_request(**query_kwargs):
29+
"""Run Query.initialize() with a stubbed control channel and return the request dict."""
30+
captured: dict = {}
31+
32+
async def _run():
33+
transport = AsyncMock()
34+
transport.is_ready = Mock(return_value=True)
35+
q = Query(transport=transport, is_streaming_mode=True, **query_kwargs)
36+
37+
async def fake_send(request, timeout):
38+
captured.update(request)
39+
return {"commands": []}
40+
41+
with patch.object(q, "_send_control_request", side_effect=fake_send):
42+
await q.initialize()
43+
44+
anyio.run(_run)
45+
return captured
46+
47+
48+
def test_initialize_sends_exclude_dynamic_sections():
49+
"""Query.initialize() includes excludeDynamicSections in the control request."""
50+
sent = _capture_initialize_request(exclude_dynamic_sections=True)
51+
assert sent["subtype"] == "initialize"
52+
assert sent["excludeDynamicSections"] is True
53+
54+
55+
def test_initialize_omits_exclude_dynamic_sections_when_unset():
56+
"""excludeDynamicSections is absent from initialize when not configured."""
57+
sent = _capture_initialize_request()
58+
assert sent["subtype"] == "initialize"
59+
assert "excludeDynamicSections" not in sent
60+
61+
2862
def _make_mock_transport(messages, control_requests=None):
2963
"""Create a mock transport that yields messages and optionally sends control requests.
3064

tests/test_types.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,23 @@ def test_claude_code_options_with_system_prompt_preset_and_append(self):
150150
"append": "Be concise.",
151151
}
152152

153+
def test_claude_code_options_with_system_prompt_preset_exclude_dynamic_sections(
154+
self,
155+
):
156+
"""Test Options with system prompt preset and exclude_dynamic_sections."""
157+
options = ClaudeAgentOptions(
158+
system_prompt={
159+
"type": "preset",
160+
"preset": "claude_code",
161+
"exclude_dynamic_sections": True,
162+
},
163+
)
164+
assert options.system_prompt == {
165+
"type": "preset",
166+
"preset": "claude_code",
167+
"exclude_dynamic_sections": True,
168+
}
169+
153170
def test_claude_code_options_with_system_prompt_file(self):
154171
"""Test Options with system prompt file."""
155172
options = ClaudeAgentOptions(

0 commit comments

Comments
 (0)