Skip to content

Commit f59e18e

Browse files
committed
feat(prompt): split static system prompt from dynamic env reminder for cache hits
Pi-ai applies Anthropic-style cache_control to our entire system prompt, but it was one block — date / cwd / git status all baked in. Every git edit or day rollover blew the cache, costing full input tokens on every turn. Move the dynamic environment block out of the system prompt entirely: - buildSystemPrompt() now produces only static content (identity, tone, anti-patterns, verification, conversation conventions, task checklist, available tools). Byte-stable across sessions for the same tool set. - buildEnvironmentReminder() produces a <system-reminder> block with cwd, platform, host, date, and git branch + dirty file summary. - App.tsx prepends the env reminder to the user's text on the first agent turn of each session (detected by zero assistant messages in the transcript). Subsequent turns inherit it via cached conversation history. Resumed sessions skip injection — the saved env from the previous session is already in the loaded transcript. - runPlanFlow accepts an optional envReminderForFirstTurn that gets glued onto the buildAgentPrompt() output before bundle.agent.prompt(), same shape as the direct path. Cache impact: on a long coding session the system prompt was rebuilt into the request every turn but cache-broken whenever git status changed. Now it stays in cache for the full 5-minute ephemeral TTL across every turn AND across sessions if the user reopens within the window. Estimated 30-50% reduction in billed input tokens on long sessions per our cost projections.
1 parent bf56bb6 commit f59e18e

6 files changed

Lines changed: 118 additions & 55 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.47",
3+
"version": "2.0.0-pre.48",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/agent/agent.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
180180
const systemPrompt =
181181
opts.systemPrompt ??
182182
buildSystemPrompt({
183-
cwd,
184183
tools: tools.map((t) => ({ name: t.name, description: t.description })),
185184
});
186185

src/agent/system-prompt.test.ts

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,42 @@ import { mkdtempSync, rmSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { describe, expect, it } from "vitest";
5-
import { buildSystemPrompt } from "./system-prompt.js";
5+
import { buildEnvironmentReminder, buildSystemPrompt } from "./system-prompt.js";
66

77
describe("buildSystemPrompt", () => {
88
it("includes the identity sentence at the top", () => {
9-
const out = buildSystemPrompt({ cwd: "/tmp" });
9+
const out = buildSystemPrompt();
1010
expect(out.startsWith("You are codebase")).toBe(true);
1111
});
1212

1313
it("includes a 'What NOT to do' anti-pattern section", () => {
14-
const out = buildSystemPrompt({ cwd: "/tmp" });
14+
const out = buildSystemPrompt();
1515
expect(out).toContain("# What NOT to do");
16-
// The specific bullets that should be present:
1716
expect(out).toMatch(/Don't add features.*beyond what was asked/);
1817
expect(out).toMatch(/Don't add error handling.*can't actually occur/);
1918
expect(out).toMatch(/Default to no comments/);
2019
});
2120

2221
it("includes the verification rule", () => {
23-
const out = buildSystemPrompt({ cwd: "/tmp" });
22+
const out = buildSystemPrompt();
2423
expect(out).toContain("# Verifying your work");
2524
expect(out).toMatch(/never characterize unverified work as complete/i);
2625
});
2726

2827
it("explains system-reminder semantics so the model knows they're from the runtime", () => {
29-
const out = buildSystemPrompt({ cwd: "/tmp" });
28+
const out = buildSystemPrompt();
3029
expect(out).toContain("<system-reminder>");
3130
expect(out).toMatch(/aren't typed by the user/i);
3231
});
3332

3433
it("includes the task-checklist policy", () => {
35-
const out = buildSystemPrompt({ cwd: "/tmp" });
34+
const out = buildSystemPrompt();
3635
expect(out).toContain("Task checklist");
3736
expect(out).toMatch(/Exactly ONE task is in_progress/);
3837
});
3938

4039
it("inlines an 'Available tools' section when tools are passed", () => {
4140
const out = buildSystemPrompt({
42-
cwd: "/tmp",
4341
tools: [
4442
{ name: "read_file", description: "Read a file from disk." },
4543
{ name: "shell", description: "Run a shell command.\n\nMore detail follows on later lines." },
@@ -53,38 +51,64 @@ describe("buildSystemPrompt", () => {
5351
});
5452

5553
it("omits the 'Available tools' section when no tools are passed", () => {
56-
const out = buildSystemPrompt({ cwd: "/tmp" });
54+
const out = buildSystemPrompt();
5755
expect(out).not.toContain("# Available tools");
5856
});
5957

60-
it("includes an Environment block with cwd / platform / date", () => {
61-
const out = buildSystemPrompt({ cwd: "/home/test" });
62-
expect(out).toContain("# Environment");
58+
it("does NOT include cwd/platform/host/date/git — those live in the env reminder now", () => {
59+
const out = buildSystemPrompt();
60+
expect(out).not.toMatch(/^- cwd:/m);
61+
expect(out).not.toMatch(/^- platform:/m);
62+
expect(out).not.toMatch(/^- date:/m);
63+
expect(out).not.toMatch(/^- git branch:/m);
64+
});
65+
66+
it("is byte-stable across calls with the same tool list (essential for prompt-cache hits)", () => {
67+
const tools = [
68+
{ name: "read_file", description: "Read." },
69+
{ name: "shell", description: "Run." },
70+
];
71+
const a = buildSystemPrompt({ tools });
72+
const b = buildSystemPrompt({ tools });
73+
expect(a).toBe(b);
74+
});
75+
76+
it("is byte-stable independent of cwd / date — those are no longer baked in", () => {
77+
const a = buildSystemPrompt();
78+
const b = buildSystemPrompt();
79+
expect(a).toBe(b);
80+
});
81+
});
82+
83+
describe("buildEnvironmentReminder", () => {
84+
it("wraps the env block in <system-reminder> tags", () => {
85+
const out = buildEnvironmentReminder("/tmp");
86+
expect(out.startsWith("<system-reminder>")).toBe(true);
87+
expect(out.endsWith("</system-reminder>")).toBe(true);
88+
});
89+
90+
it("includes cwd, platform, host, date", () => {
91+
const out = buildEnvironmentReminder("/home/test");
6392
expect(out).toContain("- cwd: /home/test");
93+
expect(out).toMatch(/- platform: /);
94+
expect(out).toMatch(/- host: /);
6495
expect(out).toMatch(/- date: \d{4}-\d{2}-\d{2}/);
6596
});
6697

67-
it("accepts a bare cwd string for backward compatibility", () => {
68-
const out = buildSystemPrompt("/legacy/cwd");
69-
expect(out).toContain("- cwd: /legacy/cwd");
98+
it("includes git summary when cwd is a git repo", () => {
99+
const out = buildEnvironmentReminder(process.cwd());
100+
expect(out).toMatch(/- git branch: \S/);
101+
expect(out).toMatch(/- git status: (clean|\d+ uncommitted)/);
70102
});
71103

72-
it("does NOT include git lines when cwd is not a git repo", () => {
104+
it("omits git lines when cwd is not a git repo", () => {
73105
const tmp = mkdtempSync(join(tmpdir(), "no-git-"));
74106
try {
75-
const out = buildSystemPrompt({ cwd: tmp });
76-
expect(out).not.toMatch(/^- git branch:/m);
77-
expect(out).not.toMatch(/^- git status:/m);
107+
const out = buildEnvironmentReminder(tmp);
108+
expect(out).not.toMatch(/- git branch:/);
109+
expect(out).not.toMatch(/- git status:/);
78110
} finally {
79111
rmSync(tmp, { recursive: true, force: true });
80112
}
81113
});
82-
83-
it("includes git branch + status when cwd is this repo", () => {
84-
// This test repo IS a git repo, so the helper should find it.
85-
const out = buildSystemPrompt({ cwd: process.cwd() });
86-
expect(out).toMatch(/^- git branch: \S/m);
87-
// status is either "clean" or "N uncommitted changes"
88-
expect(out).toMatch(/^- git status: (clean|\d+ uncommitted)/m);
89-
});
90114
});

src/agent/system-prompt.ts

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { spawnSync } from "node:child_process";
22
import { hostname, platform } from "node:os";
33

44
export interface BuildSystemPromptOptions {
5-
cwd?: string;
65
/**
76
* Active tool list. When provided, an "Available tools" section is
87
* inlined so the model doesn't have to discover tool surface area
@@ -12,24 +11,24 @@ export interface BuildSystemPromptOptions {
1211
}
1312

1413
/**
15-
* Top-level system prompt for the main agent. Layered as:
14+
* Static system prompt for the main agent. Deliberately omits every
15+
* session-specific value (cwd, date, git status, platform) so the
16+
* prompt is byte-stable across sessions for the same tool set — that's
17+
* what lets Anthropic's prompt cache hit between turns AND between
18+
* sessions. Dynamic environment context is delivered separately via
19+
* buildEnvironmentReminder() and prepended to the user's first turn.
20+
*
21+
* Layered as:
1622
* 1. Identity + verbs the agent can perform
17-
* 2. Behavior rules (anti-patterns to avoid, verification, system-reminder
18-
* semantics)
23+
* 2. Behavior rules (anti-patterns, verification, system-reminder semantics)
1924
* 3. Task-checklist policy when create_task/update_task are available
2025
* 4. Available tools, auto-built from the registered tool set
21-
* 5. Environment block (cwd, platform, date, optional git summary)
2226
*
23-
* Deliberately short — most agents on this codebase will see ~600-1000
24-
* tokens of prompt, not 6000+. The bullets are concrete things-to-avoid
25-
* rather than vibes-prompts; concrete rules bind model behavior, vague
26-
* ones don't.
27+
* Deliberately short — most agents see ~600-1000 tokens of prompt, not
28+
* 6000+. The bullets are concrete things-to-avoid rather than
29+
* vibes-prompts; concrete rules bind model behavior, vague ones don't.
2730
*/
28-
export function buildSystemPrompt(opts: BuildSystemPromptOptions | string = {}): string {
29-
// Back-compat: callers used to pass `cwd: string` directly. Accept that
30-
// shape and lift it into the options object.
31-
const options: BuildSystemPromptOptions = typeof opts === "string" ? { cwd: opts } : opts;
32-
const cwd = options.cwd ?? process.cwd();
31+
export function buildSystemPrompt(opts: BuildSystemPromptOptions = {}): string {
3332
const lines: string[] = [];
3433

3534
lines.push("You are codebase, a CLI coding agent. You help with software engineering tasks in the user's terminal.");
@@ -95,25 +94,36 @@ export function buildSystemPrompt(opts: BuildSystemPromptOptions | string = {}):
9594
);
9695
lines.push(" - Append new tasks if you discover work mid-stream; cancel tasks that turned out to be unnecessary.");
9796

98-
if (options.tools && options.tools.length > 0) {
97+
if (opts.tools && opts.tools.length > 0) {
9998
lines.push("");
10099
lines.push("# Available tools");
101-
for (const t of options.tools) {
100+
for (const t of opts.tools) {
102101
lines.push(`- ${t.name}${firstLine(t.description)}`);
103102
}
104103
}
105104

106-
lines.push("");
107-
lines.push("# Environment");
105+
return lines.join("\n");
106+
}
107+
108+
/**
109+
* Per-session environment context, delivered as a `<system-reminder>`
110+
* block at the front of the user's first turn rather than baked into
111+
* the system prompt. Keeping these values out of the system prompt is
112+
* what lets the prompt cache hit across sessions for the same tool set
113+
* — cwd, date, and git status all vary in ways that would otherwise
114+
* blow the cache every turn.
115+
*/
116+
export function buildEnvironmentReminder(cwd: string = process.cwd()): string {
117+
const lines: string[] = [];
118+
lines.push("<system-reminder>");
119+
lines.push("Environment:");
108120
lines.push(`- cwd: ${cwd}`);
109121
lines.push(`- platform: ${platform()}`);
110122
lines.push(`- host: ${hostname()}`);
111123
lines.push(`- date: ${new Date().toISOString().slice(0, 10)}`);
112-
const gitSummary = readGitSummary(cwd);
113-
if (gitSummary) {
114-
for (const line of gitSummary) lines.push(line);
115-
}
116-
124+
const git = readGitSummary(cwd);
125+
if (git) for (const line of git) lines.push(line);
126+
lines.push("</system-reminder>");
117127
return lines.join("\n");
118128
}
119129

src/plan/run-flow.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ export interface PlanFlowHandlers {
88
onReply: (text: string) => void;
99
/** Surface a plan-flow failure to the user. */
1010
onError: (message: string) => void;
11+
/**
12+
* Environment block to prepend to the final agent prompt when this is
13+
* the first agent invocation of the session. Lets the agent see cwd /
14+
* date / git status without baking those into the cacheable system
15+
* prompt. Undefined when the session already has prior assistant turns.
16+
*/
17+
envReminderForFirstTurn?: string;
1118
}
1219

1320
/**
@@ -25,7 +32,7 @@ export async function runPlanFlow(
2532
originalPrompt: string,
2633
handlers: PlanFlowHandlers,
2734
): Promise<void> {
28-
const { onReply, onError } = handlers;
35+
const { onReply, onError, envReminderForFirstTurn } = handlers;
2936
const qaHistory: QAPair[] = [];
3037
try {
3138
for (let i = 0; i < MAX_QUESTIONS; i++) {
@@ -54,7 +61,8 @@ export async function runPlanFlow(
5461
const choice = matchOption(decision, ["Yes — run it", "Revise", "Cancel"]);
5562
if (choice === "Yes — run it") {
5663
const finalPrompt = buildAgentPrompt(originalPrompt, plan, qaHistory);
57-
bundle.agent.prompt(finalPrompt).catch((err: unknown) => {
64+
const withEnv = envReminderForFirstTurn ? `${envReminderForFirstTurn}\n\n${finalPrompt}` : finalPrompt;
65+
bundle.agent.prompt(withEnv).catch((err: unknown) => {
5866
onError(err instanceof Error ? err.message : String(err));
5967
});
6068
return;

src/ui/App.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type AgentBundle, createAgent } from "../agent/agent.js";
44
import { ConfigError } from "../agent/config.js";
55
import { initialState, reducer } from "../agent/events.js";
66
import { routeUserInput } from "../agent/router.js";
7+
import { buildEnvironmentReminder } from "../agent/system-prompt.js";
78
import { BUILTIN_COMMANDS } from "../commands/builtins.js";
89
import { CommandRegistry } from "../commands/registry.js";
910
import { ConfigStore } from "../config/store.js";
@@ -217,6 +218,9 @@ function ChatApp({ initialBundle, onExit }: ChatAppProps) {
217218
await runPlanFlow(bundle, text, {
218219
onReply: (replyText) => dispatch({ type: "chat-reply", text: replyText }),
219220
onError: (message) => dispatch({ type: "error", message }),
221+
envReminderForFirstTurn: shouldInjectEnv(state.messages)
222+
? buildEnvironmentReminder(bundle.toolContext.cwd)
223+
: undefined,
220224
});
221225
return;
222226
}
@@ -226,7 +230,14 @@ function ChatApp({ initialBundle, onExit }: ChatAppProps) {
226230
appendStatus(`(router fell back to agent: ${err instanceof Error ? err.message : err})`);
227231
}
228232

229-
bundle.agent.prompt(augmentedText).catch((err: unknown) => {
233+
// First turn of a fresh (non-resumed) session carries the env block as
234+
// a system-reminder. Subsequent turns inherit it via cached transcript;
235+
// the system prompt itself stays byte-stable so the prompt cache hits
236+
// across sessions for the same tool set.
237+
const promptText = shouldInjectEnv(state.messages)
238+
? `${buildEnvironmentReminder(bundle.toolContext.cwd)}\n\n${augmentedText}`
239+
: augmentedText;
240+
bundle.agent.prompt(promptText).catch((err: unknown) => {
230241
dispatch({ type: "error", message: err instanceof Error ? err.message : String(err) });
231242
});
232243
};
@@ -405,6 +416,17 @@ function ChatApp({ initialBundle, onExit }: ChatAppProps) {
405416
);
406417
}
407418

419+
/**
420+
* True if the env reminder should be prepended to the user's next agent
421+
* prompt. We inject on the first user→agent interaction of a session
422+
* (no assistant messages in the transcript yet). For resumed sessions
423+
* the saved env from the prior run is already in the transcript, so we
424+
* skip — re-injection would just waste tokens and confuse the model.
425+
*/
426+
function shouldInjectEnv(messages: readonly { role: string }[]): boolean {
427+
return !messages.some((m) => m.role === "assistant");
428+
}
429+
408430
/**
409431
* Fetch the user's available models from the inference proxy. Used by
410432
* the ModelPicker overlay. Throws on auth / network / non-2xx so the

0 commit comments

Comments
 (0)