Skip to content

Commit 4748c35

Browse files
committed
fix: use originalWorkdir for Primary working directory to preserve prompt cache
Use immutable originalWorkdir instead of dynamic workdir (which tracks cd changes) for the Primary working directory field in the system prompt <env> section. This prevents CWD changes from invalidating the cached system prompt prefix. Changes: - buildSystemPrompt: add originalWorkdir option, use it for Primary working directory field (renamed from Working directory) - enhanceSystemPromptWithEnvDetails: add originalWorkdir parameter - aiManager: pass originalWorkdir at buildSystemPrompt call site - Remove [Working Directory] section from buildPostCompactContext (Claude Code doesn't include it; redundant with system prompt) - Update spec 021 FR-002, User Story 5, and edge cases - Add research documentation for CWD stability decision
1 parent cbe1240 commit 4748c35

5 files changed

Lines changed: 55 additions & 10 deletions

File tree

packages/agent-sdk/src/managers/aiManager.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -613,12 +613,7 @@ export class AIManager {
613613
if (usedTokens >= POST_COMPACT_TOKEN_BUDGET) break;
614614
}
615615

616-
// 2. Working directory
617-
contextParts.push(
618-
`\n\n[Working Directory]\nCurrent working directory: ${this.getWorkdir()}`,
619-
);
620-
621-
// 3. Plan mode context
616+
// 2. Plan mode context
622617
const currentMode = this.permissionManager?.getCurrentEffectiveMode(
623618
this.getModelConfig().permissionMode,
624619
);
@@ -894,6 +889,7 @@ export class AIManager {
894889
filteredToolPlugins,
895890
{
896891
workdir: this.getWorkdir(),
892+
originalWorkdir: this.getOriginalWorkdir(),
897893
memory: combinedMemory,
898894
language: this.getLanguage(),
899895
isSubagent: !!this.subagentType,

packages/agent-sdk/src/prompts/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ export function buildSystemPrompt(
236236
tools: ToolPlugin[],
237237
options: {
238238
workdir?: string;
239+
originalWorkdir?: string;
239240
memory?: string;
240241
language?: string;
241242
isSubagent?: boolean;
@@ -283,7 +284,7 @@ export function buildSystemPrompt(
283284
284285
Here is useful information about the environment you are running in:
285286
<env>
286-
Working directory: ${options.workdir}${worktreeSession ? `\nThis is a git worktree — an isolated copy of the repository. Run all commands from this directory. Do NOT \`cd\` to the original repository root at ${worktreeSession.originalCwd}.` : ""}
287+
Primary working directory: ${options.originalWorkdir ?? options.workdir}${worktreeSession ? `\nThis is a git worktree — an isolated copy of the repository. Run all commands from this directory. Do NOT \`cd\` to the original repository root at ${worktreeSession.originalCwd}.` : ""}
287288
Is directory a git repo: ${isGitRepo}
288289
Platform: ${platform}
289290
Shell: ${shellName}
@@ -310,6 +311,7 @@ Today's date: ${today}
310311
export function enhanceSystemPromptWithEnvDetails(
311312
existingSystemPrompt: string,
312313
workdir: string,
314+
originalWorkdir?: string,
313315
): string {
314316
const isGitRepo = isGitRepository(workdir);
315317
const platform = os.platform();
@@ -336,7 +338,7 @@ ${notes}
336338
337339
Here is useful information about the environment you are running in:
338340
<env>
339-
Working directory: ${workdir}${worktreeSession ? `\nThis is a git worktree — an isolated copy of the repository. Run all commands from this directory. Do NOT \`cd\` to the original repository root at ${worktreeSession.originalCwd}.` : ""}
341+
Primary working directory: ${originalWorkdir ?? workdir}${worktreeSession ? `\nThis is a git worktree — an isolated copy of the repository. Run all commands from this directory. Do NOT \`cd\` to the original repository root at ${worktreeSession.originalCwd}.` : ""}
340342
Is directory a git repo: ${isGitRepo}
341343
Platform: ${platform}
342344
Shell: ${shellName}

packages/agent-sdk/tests/prompts/prompts.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,32 @@ describe("prompts", () => {
9898
});
9999

100100
expect(result).toContain("Shell: zsh");
101-
expect(result).toContain("Working directory: /some/path");
101+
expect(result).toContain("Primary working directory: /some/path");
102102
expect(result).toContain("Is directory a git repo: Yes");
103103

104104
process.env.SHELL = originalShell;
105105
});
106106

107+
it("should use originalWorkdir for Primary working directory when provided", () => {
108+
const result = buildSystemPrompt(DEFAULT_SYSTEM_PROMPT, [], {
109+
workdir: "/some/path/subdir",
110+
originalWorkdir: "/some/path",
111+
});
112+
113+
expect(result).toContain("Primary working directory: /some/path");
114+
expect(result).not.toContain(
115+
"Primary working directory: /some/path/subdir",
116+
);
117+
});
118+
119+
it("should fall back to workdir when originalWorkdir is not provided", () => {
120+
const result = buildSystemPrompt(DEFAULT_SYSTEM_PROMPT, [], {
121+
workdir: "/some/path",
122+
});
123+
124+
expect(result).toContain("Primary working directory: /some/path");
125+
});
126+
107127
it("should handle bash shell in buildSystemPrompt", () => {
108128
const originalShell = process.env.SHELL;
109129
process.env.SHELL = "/bin/bash";

specs/021-prompt-cache-control/research.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,31 @@ interface ClaudeUsage extends CompletionUsage {
114114
- Simplified cache metrics: Rejected due to insufficient cost tracking granularity
115115
- Breaking usage interface changes: Rejected for backward compatibility requirements
116116

117+
## System Prompt Stability: CWD Changes
118+
119+
**Decision**: Use `originalWorkdir` (immutable) instead of `workdir` (dynamic, tracks `cd`) for the `Primary working directory` field in the system prompt's `<env>` section.
120+
121+
**Rationale**:
122+
- The system prompt is rebuilt every AI turn with `this.getWorkdir()` (current CWD from DI container)
123+
- When an agent runs `cd subdir` in Bash, `workdir` updates, causing the `<env>` section to change
124+
- This invalidates the entire cached system prompt prefix, negating cache benefits
125+
- Claude Code accidentally avoids this by caching/freezing the env section at first computation
126+
- Using `originalWorkdir` (set once at session start, never updated) keeps the `<env>` section stable
127+
- The model still learns about CWD changes from the Bash tool's `"Shell working directory changed to X"` output
128+
- The `buildPostCompactContext` `[Working Directory]` section was removed since it's redundant (system prompt already shows `Primary working directory`) and could vary across compactions
129+
130+
**Implementation**:
131+
- `buildSystemPrompt` options: added `originalWorkdir?: string` field
132+
- `<env>` section: `Working directory: ${options.workdir}``Primary working directory: ${options.originalWorkdir ?? options.workdir}`
133+
- `enhanceSystemPromptWithEnvDetails`: added `originalWorkdir` parameter, same change
134+
- `aiManager.ts` call site: pass `originalWorkdir: this.getOriginalWorkdir()`
135+
- `buildPostCompactContext`: removed `[Working Directory]` section (Claude Code doesn't include it)
136+
137+
**Alternatives considered**:
138+
- Show both `Primary working directory` and `Current shell directory`: Rejected because adding a varying field to the `<env>` section would still break prompt cache
139+
- Reset CWD after every bash command (like Claude Code's opt-in `CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR`): Rejected as too disruptive to agent workflow; the model may legitimately need to run commands in a subdirectory
140+
- Freeze/cached the env section like Claude Code: Rejected because Wave rebuilds the system prompt every turn (intentionally, for freshness of other fields like date); the better fix is to make the env section content stable
141+
117142
## Type Safety Strategy
118143

119144
**Decision**: Create Claude-specific type extensions without breaking existing contracts

specs/021-prompt-cache-control/spec.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ As a user who switches between permission modes (e.g., default → plan → acce
8686
2. **Given** plan mode is active, **When** the system sends the next API request, **Then** plan mode instructions MUST appear as `<system-reminder>` wrapped user messages in the messages array, not in the system prompt.
8787
3. **Given** the user exits plan mode, **When** the next API request is made, **Then** the system prompt MUST remain unchanged and usage tracking SHOULD show cache_read_input_tokens indicating a cache hit on the system message.
8888
4. **Given** a non-Claude model is configured, **When** the user enters plan mode, **Then** plan mode instructions still appear as `<system-reminder>` user messages (the injection pattern is model-agnostic, but caching benefits only apply to Claude models).
89+
5. **Given** a Claude model is configured and the system prompt has been cached, **When** the agent changes CWD via `cd subdir` in the Bash tool, **Then** the system prompt's `Primary working directory` field MUST remain unchanged (showing the original project root), and usage tracking SHOULD show cache_read_input_tokens indicating a cache hit on the system message.
8990

9091
---
9192

@@ -96,13 +97,14 @@ As a user who switches between permission modes (e.g., default → plan → acce
9697
- **Edge Case 3**: Empty conversation history MUST skip user message caching, apply system message caching only
9798
- **Edge Case 4**: Streaming and non-streaming requests MUST apply identical cache_control transformation logic
9899
- **Edge Case 5**: Token tracking MUST handle missing cache token fields gracefully (treat undefined as 0)
100+
- **Edge Case 6**: CWD changes via `cd` in Bash MUST NOT change the system prompt's `Primary working directory` field (it uses immutable `originalWorkdir`), preserving the cached system prompt prefix
99101

100102
## Requirements *(mandatory)*
101103

102104
### Functional Requirements
103105

104106
- **FR-001**: System MUST detect cache-supporting models using the `WAVE_PROMPT_CACHE_REGEX` environment variable (default: "claude"), which allows configurable regex patterns for model matching
105-
- **FR-002**: System MUST add cache_control markers with type "ephemeral" to the first system message when using Claude models. This ensures core instructions are always cached even if reminders are added later. The system prompt MUST remain constant across plan mode transitions — plan mode instructions are injected as `<system-reminder>` user messages rather than system prompt changes to preserve the cached system prompt prefix.
107+
- **FR-002**: System MUST add cache_control markers with type "ephemeral" to the first system message when using Claude models. This ensures core instructions are always cached even if reminders are added later. The system prompt MUST remain constant across plan mode transitions — plan mode instructions are injected as `<system-reminder>` user messages rather than system prompt changes to preserve the cached system prompt prefix. The `<env>` section's `Primary working directory` field MUST use the immutable `originalWorkdir` (set once at session start) rather than the dynamic `workdir` (which tracks `cd` changes), so that CWD changes do not invalidate the cached system prompt.
106108
- **FR-003**: System MUST create a cache marker when total message count reaches multiples of 20 (20, 40, 60, etc.)
107109
- **FR-004**: System MUST NOT create cache markers when total message count is below 20 or not a multiple of 20
108110
- **FR-005**: System MUST maintain cache markers at the most recent multiple-of-20 message position (sliding window)

0 commit comments

Comments
 (0)