Skip to content

feat: split system prompt into static/dynamic blocks with per-block cache_control#1330

Merged
lewis617 merged 3 commits into
mainfrom
worktree-weak-narrow-wolf
Jul 2, 2026
Merged

feat: split system prompt into static/dynamic blocks with per-block cache_control#1330
lewis617 merged 3 commits into
mainfrom
worktree-weak-narrow-wolf

Conversation

@lewis617

@lewis617 lewis617 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Split the system prompt into static/dynamic blocks with per-block cache_control, mirroring Claude Code's approach where static prompt content gets cache_control and survives cache invalidation, while dynamic content does not.

Changes

Source

  • packages/agent-sdk/src/prompts/index.ts: Added SystemPromptBlock interface { text: string; cacheable: boolean }. Changed buildSystemPrompt to return SystemPromptBlock[] instead of string, splitting into:
    • Static block (cacheable: true): base prompt + DOING_TASKS + EXECUTING_ACTIONS + TOOL_POLICY + OUTPUT_EFFICIENCY + TONE_AND_STYLE
    • Dynamic block (cacheable: false): permission mode + language + env info + auto memory + MEMORY.md
  • packages/agent-sdk/src/services/aiService.ts: Widened CallAgentOptions.systemPrompt to string | SystemPromptBlock[]. callAgent maps blocks to content parts with cache_control on cacheable blocks only for Claude models; joins to string for non-Claude models. Existing transformMessagesForExplicitCache idempotency check prevents double-marking.

Tests

  • Added 4 block structure tests in prompts.test.ts
  • Added 3 SystemPromptBlock[] input tests in aiService.cacheControl.test.ts
  • Fixed 9 existing tests across agent.systemPrompt.test.ts, aiManager.plan.test.ts, aiManager.test.ts for array-based assertions
  • All 235 test files pass (3150 tests, 1 pre-existing skip)

Spec

  • Updated specs/019-prompt-cache-control.md with user story 5, FR-008, edge cases 7-8, and SystemPromptBlock key entity

Why

Previously, the entire system prompt was a single string. Any change to dynamic content (date, MEMORY.md, permission mode) invalidated the entire cache. By separating static and dynamic content into independent blocks, the static block's cache survives dynamic content changes.

lewis617 added 3 commits July 2, 2026 20:20
…ache_control

Split buildSystemPrompt to return SystemPromptBlock[] instead of string,
separating static content (base prompt, instructions, tool policy, tone)
from dynamic content (permission mode, language, env info, auto memory,
MEMORY.md). For Claude models, callAgent maps cacheable blocks with
cache_control: {type: 'ephemeral'} and non-cacheable blocks without it,
so dynamic content changes don't invalidate the static cache.
transformMessagesForExplicitCache idempotency check prevents double-marking.

- Add SystemPromptBlock interface to prompts/index.ts
- Widen CallAgentOptions.systemPrompt to string | SystemPromptBlock[]
- Update 9 existing tests across 3 files for array-based assertions
- Add 4 block structure tests in prompts.test.ts
- Add 3 SystemPromptBlock[] input tests in aiService.cacheControl.test.ts
- Update spec 019 with user story 5, FR-008, edge cases 7-8
- All 235 test files pass (3150 tests)
Follow the agent.noParams.test.ts pattern to avoid CI environment issues:
- Add workdir: /tmp/test-system-prompt to isolate from CI filesystem
- Add apiKey: test-key for proper agent initialization
- Re-create AIManager after Agent.create() to pick up mock ToolManager
- Register mock ConfigurationService in container
- Clear mockCallAgent call history before sendMessage to ensure
  mock.calls[0] captures the sendMessage call, not any init call

The CI test process was producing zero output (crash/hang), likely due to
real MemoryService reading actual files from process.cwd() in CI.
Align with Claude Code: permission mode is handled purely at the tool
execution layer (PermissionManager), not in the system prompt. This
prevents dynamic block changes when mode switches, preserving prompt
cache.
@lewis617 lewis617 merged commit b92e2a8 into main Jul 2, 2026
1 check passed
@lewis617 lewis617 deleted the worktree-weak-narrow-wolf branch July 2, 2026 14:45
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.

1 participant