Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ The format is based on Keep a Changelog and the project follows Semantic Version

- **`antigravity-cli` provider** — Reuses a locally installed `agy` CLI the way `claude-code-cli` reuses `claude`, via `agy --print <text> --output-format stream-json`. The CLI owns its Google login in the system keyring, so the kind takes no `api_key` and no `base_url` and no credential passes through TARS. Multi-turn resumes through the stream's `conversation_id` (`--conversation`), `ReasoningEffort` maps to `--effort`, and a `json_schema` response format maps to `--json-schema`. Tools remain the CLI's own and are reported on `ChatResponse.ProviderExecutedTools` for audit only, never re-dispatched through TARS' registry. `AGY_CLI_MODE` accepts only `accept-edits` or `plan`; there is deliberately no path to `--dangerously-skip-permissions`. Requires Antigravity CLI 1.1.12 or newer.

### Fixed

- **프롬프트 캐시가 매 턴 무효화되던 문제 (#920)** — 시스템 프롬프트 첫 줄이 초 단위 wall-clock 타임스탬프라 어떤 프로바이더의 prefix 캐시에도 걸리지 않았고, 정적 본문 전체가 매 턴 write 요금으로 재과금됐다. 시각은 이제 프롬프트 맨 뒤 `## Current Time` 블록으로 내려가 분 단위로 truncate되고, `## Prior Context` 회상도 skills/style/goal/critic 등 세션 고정 섹션 **뒤**로 재배치된다. Anthropic 요청은 system을 블록 배열로 보내 `cache_control` breakpoint를 안정 블록에만 찍으므로 동적 tail이 캐시 prefix를 깨지 않는다. 정렬 규칙은 `prompt.BuildResultFor`에 불변식으로 문서화했다.
- **메모리 prefetch가 세션 작업 디렉터리를 지우던 문제 (#920)** — 채팅 메모리 캐시가 조립된 프롬프트를 통째로 저장하는 바람에, work dir을 모르는 prefetch 고루틴의 결과가 캐시에 올라가면 그 다음 턴 프롬프트에서 `## Working Directories` 섹션이 통째로 사라졌다. 이제 캐시는 회상 payload만 보관하고 프롬프트는 항상 live 옵션으로 다시 조립한다 — 캐시 hit과 miss가 구조적으로 동일한 프롬프트를 낸다.

## [0.35.0] - 2026-08-03

### Added
Expand Down
38 changes: 29 additions & 9 deletions internal/llm/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,7 @@ func (c *AnthropicClient) buildChatRequest(messages []ChatMessage, opts ChatOpti
}
}
if len(systemMessages) > 0 {
reqBody["system"] = []map[string]any{
{
"type": "text",
"text": strings.Join(systemMessages, "\n"),
"cache_control": map[string]any{
"type": "ephemeral",
},
},
}
reqBody["system"] = toAnthropicSystemBlocks(systemMessages)
}
if tools := toAnthropicTools(opts.Tools); len(tools) > 0 {
tools[len(tools)-1].CacheControl = map[string]any{"type": "ephemeral"}
Expand All @@ -133,6 +125,34 @@ func (c *AnthropicClient) buildChatRequest(messages []ChatMessage, opts ChatOpti
return reqBody
}

// toAnthropicSystemBlocks renders the collected system messages as one text
// block each and marks the cacheable prefix.
//
// The breakpoint goes on the FIRST block. Callers order their system messages
// stable-first, volatile-last (see prompt.BuildResultFor's ordering
// invariant), so the first block is the turn-stable region and everything
// after it — per-turn recall, the clock, drained critic feedback — stays
// outside the cached prefix. Anthropic's cache is prefix-matched at the
// breakpoint, so a marker placed after volatile text would write a fresh entry
// every turn and never read one.
//
// A single system message keeps the previous behavior exactly: one block,
// cached in full.
func toAnthropicSystemBlocks(systemMessages []string) []map[string]any {
blocks := make([]map[string]any, 0, len(systemMessages))
for i, text := range systemMessages {
block := map[string]any{
"type": "text",
"text": text,
}
if i == 0 {
block["cache_control"] = map[string]any{"type": "ephemeral"}
}
blocks = append(blocks, block)
}
return blocks
}

func (c *AnthropicClient) chatNonStreamingResponse(body io.Reader) (ChatResponse, error) {
respBody, err := io.ReadAll(body)
if err != nil {
Expand Down
77 changes: 77 additions & 0 deletions internal/llm/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,80 @@ func TestAnthropicChat_StreamThinkingDelta(t *testing.T) {
t.Fatalf("Content got %q", resp.Message.Content)
}
}

// A system prompt split into a stable head and a volatile tail must keep the
// cache breakpoint on the head. Marking the tail instead writes a fresh cache
// entry every turn and reads none — the LP-001 failure mode.
func TestToAnthropicSystemBlocks_BreakpointStaysOnStablePrefix(t *testing.T) {
blocks := toAnthropicSystemBlocks([]string{"static prefix", "## Current Time\n\n2026-08-22T10:23:00Z"})
if len(blocks) != 2 {
t.Fatalf("expected one block per system message, got %d", len(blocks))
}
if blocks[0]["text"] != "static prefix" {
t.Fatalf("expected stable prefix first, got %+v", blocks[0]["text"])
}
if _, ok := blocks[0]["cache_control"]; !ok {
t.Fatal("expected cache_control on the stable prefix block")
}
if _, ok := blocks[1]["cache_control"]; ok {
t.Fatal("expected no cache_control on the volatile tail block")
}
}

func TestToAnthropicSystemBlocks_SingleMessageKeepsWholePromptCached(t *testing.T) {
blocks := toAnthropicSystemBlocks([]string{"only block"})
if len(blocks) != 1 {
t.Fatalf("expected a single block, got %d", len(blocks))
}
if _, ok := blocks[0]["cache_control"]; !ok {
t.Fatal("expected cache_control on the only system block")
}
}

func TestAnthropicChat_EmitsSystemTailOutsideCachedPrefix(t *testing.T) {
var captured map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"content":[{"type":"text","text":"ok"}]}`))
}))
defer srv.Close()

client, err := NewAnthropicClient(srv.URL, "k", "claude-3-5-haiku-latest", 0)
if err != nil {
t.Fatalf("new client: %v", err)
}
_, err = client.Chat(context.Background(), []ChatMessage{
{Role: "system", Content: "stable body"},
{Role: "system", Content: "## Current Time\n\nCurrent time: 2026-08-22T10:23:00Z"},
{Role: "user", Content: "hi"},
}, ChatOptions{})
if err != nil {
t.Fatalf("chat: %v", err)
}

systemRaw, ok := captured["system"].([]any)
if !ok || len(systemRaw) != 2 {
t.Fatalf("expected two system blocks, got %+v", captured["system"])
}
head, ok := systemRaw[0].(map[string]any)
if !ok || head["text"] != "stable body" {
t.Fatalf("unexpected head block: %+v", systemRaw[0])
}
if _, ok := head["cache_control"]; !ok {
t.Fatalf("expected cache_control on head block, got %+v", head)
}
tail, ok := systemRaw[1].(map[string]any)
if !ok {
t.Fatalf("invalid tail block: %+v", systemRaw[1])
}
tailText, _ := tail["text"].(string)
if !strings.Contains(tailText, "Current time:") {
t.Fatalf("expected the clock in the tail block, got %+v", tail["text"])
}
if _, ok := tail["cache_control"]; ok {
t.Fatalf("expected no cache_control on tail block, got %+v", tail)
}
}
93 changes: 85 additions & 8 deletions internal/prompt/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ type BuildOptions struct {
StaticBudgetTokens int
RelevantBudgetTokens int
TotalBudgetTokens int
// PresetRelevant short-circuits the "## Prior Context" recall. When set,
// the builder reuses this payload verbatim instead of querying
// MemorySearcher. Callers that cache recall across turns must use this
// rather than caching the assembled prompt: the static region depends on
// live options (WorkDirs, CurrentDir, PlanClarifyMode, workspace files),
// so replaying a stored prompt ships a stale one and defeats the very
// provider prompt cache it was meant to help.
PresetRelevant *PresetRelevantMemory
}

// PresetRelevantMemory is a previously computed "## Prior Context" section,
// handed back to the builder so a repeat semantic search can be skipped.
type PresetRelevantMemory struct {
Section string
Items []RelevantMemoryItem
Tokens int
}

// RelevantMemoryItem is the structured form of one line injected into the
Expand All @@ -39,8 +55,15 @@ type RelevantMemoryItem struct {
}

// BuildResult captures prompt assembly output and budget usage.
//
// Prompt is StaticPrompt+DynamicTail. The two are also exposed separately so
// the chat assembler can keep appending its own static sections (skills,
// session style, goal, critic) *before* the dynamic tail — see the ordering
// invariant on BuildResultFor.
type BuildResult struct {
Prompt string
StaticPrompt string
DynamicTail string
StaticTokens int
RelevantTokens int
RelevantMemoryCount int
Expand All @@ -57,18 +80,31 @@ func Build(opts BuildOptions) string {

// BuildResultFor assembles a system prompt and returns budget usage details.
//
// ORDERING INVARIANT: static sections first, dynamic sections last.
//
// Provider prompt caching is prefix-matched — Anthropic against an explicit
// cache_control breakpoint, OpenAI and Gemini automatically. Anything that
// changes between two turns of the same session therefore has to sit behind
// everything that does not, or the cacheable prefix ends at the first byte
// that moved. Until LP-001 the wall-clock timestamp was the prompt's *first*
// line, so no prefix ever matched and the entire static body was re-charged
// at write rates on every single turn.
//
// Static (in order): Response Formatting, Planning, Long-running Commands,
// workspace bootstrap sections, Working Directories.
// Dynamic (BuildResult.DynamicTail, appended last): "## Prior Context"
// recall, then "## Current Time".
//
// The identity line (\"You are TARS, a personal AI assistant.\") was
// removed from the hardcoded header in ID-002(a). It now lives in the
// workspace IDENTITY.md default content, which is loaded as the
// \"## Identity\" bootstrap section below — that lets users override
// their assistant’s identity without recompiling. Response Formatting
// rules and the dynamic \"Current time\" line stay in code: they describe
// runtime constraints, not user-tunable persona.
// rules and the dynamic time line stay in code: they describe runtime
// constraints, not user-tunable persona.
func BuildResultFor(opts BuildOptions) BuildResult {
var b strings.Builder

b.WriteString(fmt.Sprintf("Current time: %s\n", time.Now().UTC().Format(time.RFC3339)))
b.WriteString("\n")
b.WriteString("## Response Formatting\n\n")
b.WriteString("Always use rich Markdown in your responses:\n")
b.WriteString("- Use headings, bold, lists, and tables to structure information clearly.\n")
Expand Down Expand Up @@ -129,7 +165,11 @@ func BuildResultFor(opts BuildOptions) BuildResult {
if totalBudgetTokens <= 0 {
totalBudgetTokens = defaultTotalBudgetTokens
}
totalTokens := estimateTokens(b.String())
// The clock block is rendered last but charged here: it is always emitted,
// so reserving it up front keeps the static sections clamped inside the
// total budget exactly as they were when the timestamp led the prompt.
timeSection := currentTimeSection()
totalTokens := estimateTokens(b.String()) + estimateTokens(timeSection)
remainingTotalTokens := max(0, totalBudgetTokens-totalTokens)

remainingStaticTokens := opts.StaticBudgetTokens
Expand Down Expand Up @@ -196,29 +236,49 @@ func BuildResultFor(opts BuildOptions) BuildResult {
remainingTotalTokens -= sectionTokens
}

// Everything written above is static for the lifetime of a session (it
// only moves when a workspace bootstrap file or a session setting
// changes). Everything below is per-turn and must stay behind it.
staticPrompt := b.String()

relevantTokens := 0
relevantCount := 0
relevantBudgetTokens := 0
relevantSection := ""
var relevantItems []RelevantMemoryItem
usedTokens := 0
var tail strings.Builder
if !opts.SubAgent {
relevantBudgetTokens = opts.RelevantBudgetTokens
if relevantBudgetTokens <= 0 {
relevantBudgetTokens = defaultRelevantBudgetTokens
}
relevantBudgetTokens = min(relevantBudgetTokens, remainingTotalTokens)
relevantSection, relevantItems, usedTokens = buildRelevantMemorySection(opts, relevantBudgetTokens)
if opts.PresetRelevant != nil {
relevantSection = opts.PresetRelevant.Section
relevantItems = append([]RelevantMemoryItem(nil), opts.PresetRelevant.Items...)
usedTokens = opts.PresetRelevant.Tokens
} else {
relevantSection, relevantItems, usedTokens = buildRelevantMemorySection(opts, relevantBudgetTokens)
}
if relevantSection != "" {
b.WriteString(relevantSection)
tail.WriteString(relevantSection)
relevantTokens = usedTokens
relevantCount = len(relevantItems)
totalTokens += usedTokens
}
}

// Recall changes with the user's query; the clock changes on its own. The
// clock goes last so that re-running an identical query inside the same
// minute still matches through the recall block.
tail.WriteString(timeSection)

dynamicTail := tail.String()
return BuildResult{
Prompt: b.String(),
Prompt: staticPrompt + dynamicTail,
StaticPrompt: staticPrompt,
DynamicTail: dynamicTail,
StaticTokens: staticTokens,
RelevantTokens: relevantTokens,
RelevantMemoryCount: relevantCount,
Expand All @@ -229,6 +289,23 @@ func BuildResultFor(opts BuildOptions) BuildResult {
}
}

// timeNow is a seam so tests can build the prompt at two different wall-clock
// instants and compare the resulting prefixes.
var timeNow = time.Now

// currentTimeSection renders the dynamic clock block that closes every prompt.
//
// The timestamp is truncated to the minute rather than the second. Second
// resolution is more precision than any assistant answer needs, and it would
// re-break the prefix on every retry, tool-loop restart, or rapid follow-up —
// which is exactly the burst where a cache hit is worth the most.
func currentTimeSection() string {
return fmt.Sprintf(
"\n## Current Time\n\nCurrent time: %s\n",
timeNow().UTC().Truncate(time.Minute).Format(time.RFC3339),
)
}

func readBootstrapSection(workspaceDir string, section bootstrapSection) string {
parts := make([]string, 0, len(section.files))
for _, name := range section.files {
Expand Down
Loading
Loading