Skip to content

Add exclude_dynamic_sections to SystemPromptPreset for cross-user caching#797

Merged
qing-ant merged 2 commits intomainfrom
qing/exclude-dynamic-sections
Apr 8, 2026
Merged

Add exclude_dynamic_sections to SystemPromptPreset for cross-user caching#797
qing-ant merged 2 commits intomainfrom
qing/exclude-dynamic-sections

Conversation

@qing-ant
Copy link
Copy Markdown
Contributor

@qing-ant qing-ant commented Apr 7, 2026

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

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

Parity with the TypeScript SDK option of the same name. When set, the CLI strips per-user dynamic sections (cwd, auto-memory, git status) from the preset system prompt and re-injects them into the first user message, so the system prompt stays static and cacheable across users.

Closes #784
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the inline comment flags the CHANGELOG reference nit.

Extended reasoning...

Overview

The PR adds a single optional field exclude_dynamic_sections to SystemPromptPreset and threads it through the type system (types.py) → both client paths (_internal/client.py, client.py) → Query.__init__ (_internal/query.py) → the initialize control request as camelCase excludeDynamicSections. Seven files changed, but the diff is small and mechanical.

Security risks

None. The field is a boolean sent only in the initialize control message to the CLI subprocess. No external input is accepted without type-checking (isinstance(eds, bool)), and older CLIs silently ignore unknown initialize fields.

Level of scrutiny

Low. This is a pure SDK-parity feature (matching the TypeScript SDK), with no new permissions, no auth changes, and no behavioral impact on the main code path when omitted. The logic is a straightforward conditional inclusion.

Other factors

Both unit tests added in test_query.py directly verify that the field is sent when set and absent when unset. test_types.py covers the Options round-trip. The only issue found is the CHANGELOG referencing issue #784 instead of PR #797 — already flagged as an inline nit.

@qing-ant
Copy link
Copy Markdown
Contributor Author

qing-ant commented Apr 8, 2026

E2E Test Results

Method: Python SDK from this branch, pointed at a locally built Claude Code CLI containing the corresponding handling for excludeDynamicSections. Four sequential real API calls against claude-haiku-4-5, two distinct git-repo cwd values with differing git status.

Test script

e2e_797.py
"""E2E proof for #797: exclude_dynamic_sections cross-user caching."""

import anyio

from claude_agent_sdk import (
    AssistantMessage,
    ClaudeAgentOptions,
    ResultMessage,
    TextBlock,
    query,
)

CLI = "/Users/qing/code/wt-cli-e2e/build-external/cli.js"
ALICE = "/Users/qing/code/tmp/e2e-alice-git"
BOB = "/Users/qing/code/tmp/e2e-bob-git"
PROMPT = (
    "What is my primary working directory? Reply with only the absolute path, "
    "nothing else."
)


async def run(label: str, cwd: str, exclude: bool) -> dict:
    sp: dict = {"type": "preset", "preset": "claude_code"}
    if exclude:
        sp["exclude_dynamic_sections"] = True
    opts = ClaudeAgentOptions(
        cwd=cwd,
        max_turns=1,
        model="claude-haiku-4-5-20251001",
        system_prompt=sp,
        allowed_tools=[],
        cli_path=CLI,
    )
    answer = ""
    usage: dict = {}
    async for msg in query(prompt=PROMPT, options=opts):
        if isinstance(msg, AssistantMessage):
            for block in msg.content:
                if isinstance(block, TextBlock):
                    answer += block.text
        elif isinstance(msg, ResultMessage):
            usage = msg.usage or {}
    cc = usage.get("cache_creation_input_tokens", "-")
    cr = usage.get("cache_read_input_tokens", "-")
    print(
        f"{label} | cwd={cwd} | answer={answer.strip()!r} | "
        f"cache_creation={cc} cache_read={cr}"
    )
    return {"answer": answer.strip(), "cc": cc, "cr": cr}


async def main() -> None:
    print("=== PR #797 e2e: exclude_dynamic_sections (Python SDK) ===\n")
    r1 = await run("1-default-alice ", ALICE, exclude=False)
    r2 = await run("2-default-bob   ", BOB, exclude=False)
    r3 = await run("3-exclude-alice ", ALICE, exclude=True)
    r4 = await run("4-exclude-bob   ", BOB, exclude=True)

    print("\n=== Analysis ===")
    ok1 = "e2e-alice-git" in r1["answer"]
    ok3 = "e2e-alice-git" in r3["answer"] and "e2e-bob-git" in r4["answer"]
    print(f"default mode knows cwd (no regression):     {'PASS' if ok1 else 'FAIL'}")
    print(f"exclude mode still knows cwd (re-inject):   {'PASS' if ok3 else 'FAIL'}")
    print(f"default cross-cwd cache_creation:           {r2['cc']}")
    print(f"exclude cross-cwd cache_creation:           {r4['cc']}")
    if isinstance(r2["cc"], int) and isinstance(r4["cc"], int):
        delta = r2["cc"] - r4["cc"]
        print(f"savings (lower is better in exclude):       {delta} tokens")


anyio.run(main)

Output

=== PR #797 e2e: exclude_dynamic_sections (Python SDK) ===

1-default-alice  | cwd=/Users/qing/code/tmp/e2e-alice-git | answer='/Users/qing/code/tmp/e2e-alice-git' | cache_creation=73868 cache_read=0
2-default-bob    | cwd=/Users/qing/code/tmp/e2e-bob-git | answer='/Users/qing/code/tmp/e2e-bob-git' | cache_creation=9915 cache_read=63961
3-exclude-alice  | cwd=/Users/qing/code/tmp/e2e-alice-git | answer='/Users/qing/code/tmp/e2e-alice-git' | cache_creation=9908 cache_read=63961
4-exclude-bob    | cwd=/Users/qing/code/tmp/e2e-bob-git | answer='/Users/qing/code/tmp/e2e-bob-git' | cache_creation=7065 cache_read=66812

=== Analysis ===
default mode knows cwd (no regression):     PASS
exclude mode still knows cwd (re-inject):   PASS
default cross-cwd cache_creation:           9915
exclude cross-cwd cache_creation:           7065
savings (lower is better in exclude):       2850 tokens

Verdict

Check Result
Default mode knows cwd (no regression) ✅ PASS
Exclude mode still knows cwd (re-injection works) ✅ PASS
Option reaches the CLI (system prompt changes) ✅ run 3 read 63961 (shared prefix), not 73868 (full default)
Cross-cwd cache improves cache_creation 9915 → 7065 (−29%); run 4 reads run 3's full system-prompt cache

Summary: exclude_dynamic_sections=True is correctly threaded through the Python SDK's initialize control message; with a CLI that supports it, the system prompt becomes byte-identical across different cwd values (run 4 cache-reads run 3's full prompt), and the model still receives the working-directory info via the first user message. Default behavior unchanged.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — my prior CHANGELOG comment was addressed by removing the edit entirely.

Extended reasoning...

Overview

Adds exclude_dynamic_sections: NotRequired[bool] to SystemPromptPreset, threads it through both ClaudeSDKClient.connect() and InternalClient.process_query(), and includes it in the initialize control request under the camelCase wire-format key excludeDynamicSections. Six files touched: the two client paths, Query, types.py, and two test modules.

Security risks

None. This is an opt-in flag forwarded to the CLI via the existing control protocol. The CLI ignores unknown initialize fields on older versions, so there is no injection or bypass risk.

Level of scrutiny

Low. The change is mechanical parity with an existing TypeScript SDK option. It strictly follows the pattern already established for agents / _agents: extract from options → store on Query → conditionally include in initialize request. No auth, crypto, or permission-sensitive paths are touched.

Other factors

Two focused unit tests cover the presence and absence of the field in the initialize payload. The single prior review concern (wrong CHANGELOG entry number) was resolved by removing the manual CHANGELOG edit, which is consistent with the repo's auto-generation workflow. No outstanding reviewer comments remain.

@qing-ant qing-ant enabled auto-merge (squash) April 8, 2026 03:36
@qing-ant qing-ant merged commit 3bf8fd5 into main Apr 8, 2026
10 checks passed
@qing-ant qing-ant deleted the qing/exclude-dynamic-sections branch April 8, 2026 03:40
@qing-ant
Copy link
Copy Markdown
Contributor Author

qing-ant commented Apr 8, 2026

E2E Test Results — with large append block

Validates that savings scale with append size (matching the TypeScript SDK e2e). Same setup as the previous comment: Python SDK from this branch + CLI built from the corresponding unreleased change. Real API calls against claude-haiku-4-5, two git-repo cwd values with differing status, ~72k-char append (~14k tokens measured).

Output

=== PR 797 e2e: exclude_dynamic_sections with ~6k-token append ===
CLI: <unreleased CLI build with corresponding support>
append size: 72200 chars

1-default-alice  | cwd=/Users/qing/code/tmp/e2e-alice-git | answer='/Users/qing/code/tmp/e2e-alice-git' | cache_creation=88273 cache_read=0
2-default-bob    | cwd=/Users/qing/code/tmp/e2e-bob-git | answer='/Users/qing/code/tmp/e2e-bob-git' | cache_creation=24315 cache_read=63966
3-exclude-alice  | cwd=/Users/qing/code/tmp/e2e-alice-git | answer='/Users/qing/code/tmp/e2e-alice-git' | cache_creation=24309 cache_read=63966
4-exclude-bob    | cwd=/Users/qing/code/tmp/e2e-bob-git | answer='/Users/qing/code/tmp/e2e-bob-git' | cache_creation=7065 cache_read=81218

=== Analysis ===
default mode knows cwd (no regression):     PASS
exclude mode still knows cwd (re-inject):   PASS
cross-cwd cache_creation: 24315 -> 7065 (-71%, 17250 tokens saved)

Verdict

Check Result
Default mode knows cwd (no regression) ✅ PASS
Exclude mode knows cwd (re-injection works) ✅ PASS
Cross-cwd cache_creation 24,315 → 7,065 (−71%, 17,250 tokens saved)
Exclude-mode residual independent of append size ✅ 7065 — identical to the no-append run
Run 4 reads run 3's full system-prompt cache cache_read=81218 (vs 63966 in default mode)

Summary: With exclude_dynamic_sections=True, the entire system prompt including the large append block caches cross-user (run 4 reads 81k from run 3's cache despite a different cwd and git status). Only the per-user first user message (~7k) is recreated. Without the flag, the append block plus everything after the cwd-varying bytes (~24k) is a per-user miss. Savings scale linearly with append size.

Test script (e2e_797_append.py)
"""E2E: exclude_dynamic_sections with ~6k-token append (PR #797)."""

import asyncio

from claude_agent_sdk import ClaudeAgentOptions, query

CLI = "<path-to-unreleased-cli>"
ALICE = "/Users/qing/code/tmp/e2e-alice-git"
BOB = "/Users/qing/code/tmp/e2e-bob-git"
PROMPT = (
    "What is my primary working directory? Reply with only the absolute path, "
    "nothing else."
)
# ~74k chars ≈ ~6k tokens (highly repetitive text tokenizes efficiently)
APPEND = ("You are a helpful coding assistant. " * 10 + "\n") * 200


async def run(label: str, cwd: str, exclude: bool) -> dict:
    sp: dict = {"type": "preset", "preset": "claude_code", "append": APPEND}
    if exclude:
        sp["exclude_dynamic_sections"] = True
    opts = ClaudeAgentOptions(
        cwd=cwd,
        max_turns=1,
        model="claude-haiku-4-5-20251001",
        system_prompt=sp,
        allowed_tools=[],
        cli_path=CLI,
    )
    answer = ""
    usage: dict = {}
    err = None
    try:
        async for msg in query(prompt=PROMPT, options=opts):
            name = type(msg).__name__
            if name == "AssistantMessage":
                for block in getattr(msg, "content", []):
                    if type(block).__name__ == "TextBlock":
                        answer += getattr(block, "text", "")
            elif name == "ResultMessage":
                usage = getattr(msg, "usage", None) or {}
    except Exception as e:  # noqa: BLE001
        err = f"{type(e).__name__}: {e}"
    cc = usage.get("cache_creation_input_tokens", "-")
    cr = usage.get("cache_read_input_tokens", "-")
    line = (
        f"{label:16s} | cwd={cwd} | answer={answer.strip()!r} | "
        f"cache_creation={cc} cache_read={cr}"
    )
    if err:
        line += f" | ERROR={err}"
    print(line)
    return {"answer": answer.strip(), "cc": cc, "cr": cr, "err": err}


async def main() -> None:
    print("=== PR 797 e2e: exclude_dynamic_sections with ~6k-token append ===")
    print(f"CLI: {CLI}")
    print(f"append size: {len(APPEND)} chars\n")
    r1 = await run("1-default-alice", ALICE, exclude=False)
    r2 = await run("2-default-bob", BOB, exclude=False)
    r3 = await run("3-exclude-alice", ALICE, exclude=True)
    r4 = await run("4-exclude-bob", BOB, exclude=True)

    print("\n=== Analysis ===")
    ok1 = "e2e-alice-git" in r1["answer"]
    ok34 = "e2e-alice-git" in r3["answer"] and "e2e-bob-git" in r4["answer"]
    print(f"default mode knows cwd (no regression):     {'PASS' if ok1 else 'FAIL'}")
    print(f"exclude mode still knows cwd (re-inject):   {'PASS' if ok34 else 'FAIL'}")
    d = r2["cc"] if isinstance(r2["cc"], int) else 0
    e = r4["cc"] if isinstance(r4["cc"], int) else 0
    if d and e:
        pct = round(100 * (d - e) / d)
        print(f"cross-cwd cache_creation: {d} -> {e} (-{pct}%, {d - e} tokens saved)")
    else:
        print(f"cross-cwd cache_creation: {d} -> {e}")


if __name__ == "__main__":
    asyncio.run(main())

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Preset system prompt contains per-user dynamic content, breaking cross-user prompt caching

2 participants