From 248ada3cc662bf0529bbf9586bdfaee6aee2467c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:06:33 +0100 Subject: [PATCH 1/3] fix(ai): cache the stable system prompt prefix Anthropic only received a cache breakpoint on the last tool, so the large frozen system prompt was never cached and multi-turn Patrol investigations repaid its full input cost every turn (#2118). Serialize the stable base plus mode text as a system content block with its own ephemeral breakpoint, leaving the per-turn clock and accumulated knowledge outside the cached prefix. The system breakpoint covers the tool definitions too, so the redundant tool breakpoint is dropped when it is present. Providers without prompt caching ignore the new request field and send the system prompt unchanged. Cache creation/read counters are logged at Info so effectiveness is visible in production. Change-source: pulse-maintainer --- internal/ai/chat/agentic.go | 18 ++- internal/ai/chat/agentic_prompt.go | 15 ++- internal/ai/chat/agentic_test.go | 24 ++++ internal/ai/providers/anthropic.go | 76 +++++++++-- internal/ai/providers/anthropic_test.go | 168 ++++++++++++++++++++++++ internal/ai/providers/provider.go | 17 ++- 6 files changed, 296 insertions(+), 22 deletions(-) diff --git a/internal/ai/chat/agentic.go b/internal/ai/chat/agentic.go index ad0600fac4..79921ad7f9 100644 --- a/internal/ai/chat/agentic.go +++ b/internal/ai/chat/agentic.go @@ -871,14 +871,18 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me Str("session_id", sessionID). Msg("[AgenticLoop] Starting turn") - // Build the request with dynamic system prompt (includes current mode) - systemPrompt := a.getSystemPrompt() + // Build the request with dynamic system prompt (includes current mode). + // Mark the frozen base plus mode text as cacheable so a prompt-caching + // provider reuses it across turns without caching the per-turn time or + // accumulated knowledge that follow it. + stablePrompt, systemPrompt := a.systemPromptParts() req := providers.ChatRequest{ - Messages: providerMessages, - System: systemPrompt, - Tools: tools, - ExecutionID: a.executionID, - StreamIdleTimeout: streamIdleTimeout, + Messages: providerMessages, + System: systemPrompt, + SystemCacheablePrefix: stablePrompt, + Tools: tools, + ExecutionID: a.executionID, + StreamIdleTimeout: streamIdleTimeout, } if isPatrolDetectionExecution(a.currentExecutionProfile()) && patrolFindingsReadCompleted { req.Tools = withoutProviderTool(req.Tools, agentcapabilities.PatrolGetFindingsToolName) diff --git a/internal/ai/chat/agentic_prompt.go b/internal/ai/chat/agentic_prompt.go index 02bac7579c..cd68f394c2 100644 --- a/internal/ai/chat/agentic_prompt.go +++ b/internal/ai/chat/agentic_prompt.go @@ -13,6 +13,16 @@ import ( // service start, so anything that must stay fresh per turn (mode, current time) // is appended here rather than baked into baseSystemPrompt. func (a *AgenticLoop) getSystemPrompt() string { + _, prompt := a.systemPromptParts() + return prompt +} + +// systemPromptParts returns the full system prompt together with its stable +// leading prefix. The prefix (frozen base plus mode text) is identical across +// the turns of a run, so a provider with prompt caching can reuse it; the +// per-turn time and accumulated knowledge that follow it change every turn and +// must stay outside the cached block. +func (a *AgenticLoop) systemPromptParts() (stablePrefix, full string) { a.mu.Lock() isAutonomous := a.autonomousMode profile := a.executionProfile @@ -68,12 +78,13 @@ Treat this as the current date and time. Answer "what time is it" / "what's the questions directly from this value — do not run a command or ask for a target host just to report the current time.`, time.Now().Format("Mon, 02 Jan 2006 15:04:05 MST")) - prompt := a.baseSystemPrompt + modeContext + currentTime + stablePrefix = a.baseSystemPrompt + modeContext + prompt := stablePrefix + currentTime // Append accumulated knowledge facts to system prompt if ka := a.knowledgeAccumulator; ka != nil && ka.Len() > 0 { prompt += "\n\n" + ka.Render() } - return prompt + return stablePrefix, prompt } diff --git a/internal/ai/chat/agentic_test.go b/internal/ai/chat/agentic_test.go index 066179e902..58a0b35231 100644 --- a/internal/ai/chat/agentic_test.go +++ b/internal/ai/chat/agentic_test.go @@ -461,6 +461,30 @@ func TestAgenticLoop_SystemPromptIncludesCurrentTime(t *testing.T) { } } +func TestAgenticLoop_SystemPromptPartsSeparateStablePrefix(t *testing.T) { + mockProvider := &MockProvider{} + executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{}) + loop := NewAgenticLoop(mockProvider, executor, "BASE PROMPT BODY") + + stable, full := loop.systemPromptParts() + + if !strings.HasPrefix(full, stable) { + t.Fatalf("stable prefix must lead the full prompt, stable=%q full=%q", stable, full) + } + if !strings.Contains(stable, "BASE PROMPT BODY") || !strings.Contains(stable, "EXECUTION MODE:") { + t.Fatalf("stable prefix must carry the frozen base and mode text, got %q", stable) + } + if strings.Contains(stable, "CURRENT TIME:") { + t.Fatalf("per-turn time must stay outside the cacheable prefix, got %q", stable) + } + if !strings.Contains(full, "CURRENT TIME:") { + t.Fatalf("full prompt must still carry CURRENT TIME, got %q", full) + } + if loop.getSystemPrompt() != full { + t.Fatal("getSystemPrompt must return the full prompt from systemPromptParts") + } +} + func TestAgenticLoop_AnswerQuestion(t *testing.T) { mockProvider := &MockProvider{} executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{}) diff --git a/internal/ai/providers/anthropic.go b/internal/ai/providers/anthropic.go index fb69c75fd2..b4ba514002 100644 --- a/internal/ai/providers/anthropic.go +++ b/internal/ai/providers/anthropic.go @@ -67,7 +67,7 @@ type anthropicRequest struct { Model string `json:"model"` Messages []anthropicMessage `json:"messages"` MaxTokens int `json:"max_tokens"` - System string `json:"system,omitempty"` + System interface{} `json:"system,omitempty"` Temperature float64 `json:"temperature,omitempty"` Tools []anthropicTool `json:"tools,omitempty"` ToolChoice *anthropicToolChoice `json:"tool_choice,omitempty"` @@ -114,6 +114,48 @@ type anthropicCacheControl struct { Type string `json:"type"` // "ephemeral" } +// anthropicSystemBlock is a system-prompt content block. Anthropic accepts the +// system prompt as either a plain string or an array of content blocks; the +// array form is required to place a prompt-caching breakpoint on the stable +// leading portion of the prompt. +type anthropicSystemBlock struct { + Type string `json:"type"` // "text" + Text string `json:"text"` + CacheControl *anthropicCacheControl `json:"cache_control,omitempty"` +} + +// buildAnthropicSystem serializes the system prompt for an Anthropic request. +// When cacheablePrefix is a non-empty exact prefix of system, the stable prefix +// is returned as a content block with a cache breakpoint and the volatile +// remainder as a second block. Anthropic caches content in tools -> system -> +// messages order, so this breakpoint also covers the tool definitions and the +// redundant per-tool breakpoint can be dropped. Otherwise the plain string form +// is returned unchanged. +func buildAnthropicSystem(system, cacheablePrefix string) interface{} { + if system == "" { + return nil + } + if cacheablePrefix == "" || !strings.HasPrefix(system, cacheablePrefix) { + return system + } + blocks := []anthropicSystemBlock{{ + Type: "text", + Text: cacheablePrefix, + CacheControl: &anthropicCacheControl{Type: "ephemeral"}, + }} + if remainder := system[len(cacheablePrefix):]; remainder != "" { + blocks = append(blocks, anthropicSystemBlock{Type: "text", Text: remainder}) + } + return blocks +} + +// anthropicSystemIsCached reports whether buildAnthropicSystem returned the +// block form, meaning a system breakpoint already covers the tool definitions. +func anthropicSystemIsCached(system interface{}) bool { + _, ok := system.([]anthropicSystemBlock) + return ok +} + // anthropicResponse is the response from the Anthropic API type anthropicResponse struct { ID string `json:"id"` @@ -245,11 +287,12 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo maxTokens = 4096 } + systemValue := buildAnthropicSystem(req.System, req.SystemCacheablePrefix) anthropicReq := anthropicRequest{ Model: model, Messages: messages, MaxTokens: maxTokens, - System: req.System, + System: systemValue, } if req.Temperature > 0 { @@ -279,8 +322,12 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo } } // Mark the last tool with cache_control so Anthropic caches all tool - // definitions (and everything before them) on subsequent turns. - anthropicReq.Tools[len(anthropicReq.Tools)-1].CacheControl = &anthropicCacheControl{Type: "ephemeral"} + // definitions (and everything before them) on subsequent turns. A + // system cache breakpoint already covers the tool definitions, so it is + // omitted then to avoid a redundant cache write. + if !anthropicSystemIsCached(systemValue) { + anthropicReq.Tools[len(anthropicReq.Tools)-1].CacheControl = &anthropicCacheControl{Type: "ephemeral"} + } } // Add tool_choice only for explicit overrides. Nil keeps Anthropic's default @@ -417,6 +464,15 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo logEvent = logEvent. Int("cache_creation_tokens", anthropicResp.Usage.CacheCreationInputTokens). Int("cache_read_tokens", anthropicResp.Usage.CacheReadInputTokens) + // Prompt-cache effectiveness is otherwise invisible in production + // because the parsed-response event is Debug. Surface the counters at + // Info so operators can confirm cache reads and writes from real runs. + log.Info(). + Int("input_tokens", anthropicResp.Usage.InputTokens). + Int("cache_creation_tokens", anthropicResp.Usage.CacheCreationInputTokens). + Int("cache_read_tokens", anthropicResp.Usage.CacheReadInputTokens). + Str("model", anthropicResp.Model). + Msg("anthropic prompt cache usage") } logEvent.Msg("anthropic response parsed") @@ -542,7 +598,7 @@ type anthropicStreamRequest struct { Model string `json:"model"` Messages []anthropicMessage `json:"messages"` MaxTokens int `json:"max_tokens"` - System string `json:"system,omitempty"` + System interface{} `json:"system,omitempty"` Temperature float64 `json:"temperature,omitempty"` Tools []anthropicTool `json:"tools,omitempty"` ToolChoice *anthropicToolChoice `json:"tool_choice,omitempty"` @@ -587,11 +643,12 @@ func (c *AnthropicClient) ChatStream(ctx context.Context, req ChatRequest, callb maxTokens = 4096 } + systemValue := buildAnthropicSystem(req.System, req.SystemCacheablePrefix) anthropicReq := anthropicStreamRequest{ Model: model, Messages: messages, MaxTokens: maxTokens, - System: req.System, + System: systemValue, Stream: true, } @@ -620,8 +677,11 @@ func (c *AnthropicClient) ChatStream(ctx context.Context, req ChatRequest, callb } } } - // Mark the last tool with cache_control for prompt caching (same as non-streaming). - anthropicReq.Tools[len(anthropicReq.Tools)-1].CacheControl = &anthropicCacheControl{Type: "ephemeral"} + // Mark the last tool with cache_control for prompt caching (same as + // non-streaming), unless a system breakpoint already covers the tools. + if !anthropicSystemIsCached(systemValue) { + anthropicReq.Tools[len(anthropicReq.Tools)-1].CacheControl = &anthropicCacheControl{Type: "ephemeral"} + } } // Add tool_choice only for explicit overrides, same as non-streaming. diff --git a/internal/ai/providers/anthropic_test.go b/internal/ai/providers/anthropic_test.go index 12ff434f0d..4879e59931 100644 --- a/internal/ai/providers/anthropic_test.go +++ b/internal/ai/providers/anthropic_test.go @@ -734,3 +734,171 @@ func TestAnthropicClient_SupportsThinking(t *testing.T) { t.Fatal("expected SupportsThinking to be false") } } + +func TestBuildAnthropicSystem(t *testing.T) { + t.Run("plain string when no cacheable prefix", func(t *testing.T) { + got := buildAnthropicSystem("base+mode+time", "") + if s, ok := got.(string); !ok || s != "base+mode+time" { + t.Fatalf("buildAnthropicSystem = %#v, want plain string", got) + } + if anthropicSystemIsCached(got) { + t.Fatal("plain system must not be reported as cached") + } + }) + + t.Run("non-prefix falls back to plain string", func(t *testing.T) { + got := buildAnthropicSystem("base+mode+time", "different") + if _, ok := got.(string); !ok { + t.Fatalf("buildAnthropicSystem = %#v, want plain string fallback", got) + } + }) + + t.Run("splits stable prefix with a cache breakpoint", func(t *testing.T) { + got := buildAnthropicSystem("base+mode+time", "base+mode") + blocks, ok := got.([]anthropicSystemBlock) + if !ok || len(blocks) != 2 { + t.Fatalf("buildAnthropicSystem = %#v, want two blocks", got) + } + if blocks[0].Text != "base+mode" || blocks[0].CacheControl == nil || blocks[0].CacheControl.Type != "ephemeral" { + t.Fatalf("unexpected stable block: %+v", blocks[0]) + } + if blocks[1].Text != "+time" || blocks[1].CacheControl != nil { + t.Fatalf("unexpected volatile block: %+v", blocks[1]) + } + if !anthropicSystemIsCached(got) { + t.Fatal("block system must be reported as cached") + } + }) +} + +func TestAnthropicClient_Chat_CacheableSystemPrefix(t *testing.T) { + var got anthropicRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(anthropicResponse{ + ID: "msg_1", + Type: "message", + Role: "assistant", + Model: "claude-3-5-sonnet", + StopReason: "end_turn", + Content: []anthropicContent{{Type: "text", Text: "ok"}}, + Usage: anthropicUsage{InputTokens: 1, OutputTokens: 1}, + }) + })) + defer server.Close() + + client := NewAnthropicClientWithBaseURL("test-key", "claude-3-5-sonnet", server.URL, 0) + if _, err := client.Chat(context.Background(), ChatRequest{ + System: "stable promptCURRENT TIME: now", + SystemCacheablePrefix: "stable prompt", + Messages: []Message{{Role: "user", Content: "Hi"}}, + Tools: []Tool{{Name: "get_time", Description: "d", InputSchema: map[string]any{"type": "object"}}}, + }); err != nil { + t.Fatalf("Chat: %v", err) + } + + blocks, ok := got.System.([]interface{}) + if !ok || len(blocks) != 2 { + t.Fatalf("system = %#v, want two content blocks", got.System) + } + first, _ := blocks[0].(map[string]interface{}) + if first["text"] != "stable prompt" { + t.Fatalf("first block = %#v", first) + } + cc, _ := first["cache_control"].(map[string]interface{}) + if cc["type"] != "ephemeral" { + t.Fatalf("first block cache_control = %#v", first["cache_control"]) + } + second, _ := blocks[1].(map[string]interface{}) + if second["text"] != "CURRENT TIME: now" { + t.Fatalf("second block = %#v", second) + } + if _, present := second["cache_control"]; present { + t.Fatalf("volatile block must not carry a breakpoint: %#v", second) + } + if got.Tools[0].CacheControl != nil { + t.Fatalf("tool breakpoint must be dropped when the system block is cached: %+v", got.Tools[0]) + } +} + +func TestAnthropicClient_Chat_KeepsToolBreakpointWithoutCacheableSystem(t *testing.T) { + var got anthropicRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(anthropicResponse{ + ID: "msg_1", + Type: "message", + Role: "assistant", + Model: "claude-3-5-sonnet", + StopReason: "end_turn", + Content: []anthropicContent{{Type: "text", Text: "ok"}}, + Usage: anthropicUsage{InputTokens: 1, OutputTokens: 1}, + }) + })) + defer server.Close() + + client := NewAnthropicClientWithBaseURL("test-key", "claude-3-5-sonnet", server.URL, 0) + if _, err := client.Chat(context.Background(), ChatRequest{ + System: "plain prompt", + Messages: []Message{{Role: "user", Content: "Hi"}}, + Tools: []Tool{{Name: "get_time", Description: "d", InputSchema: map[string]any{"type": "object"}}}, + }); err != nil { + t.Fatalf("Chat: %v", err) + } + if _, ok := got.System.(string); !ok { + t.Fatalf("system = %#v, want plain string", got.System) + } + if got.Tools[0].CacheControl == nil { + t.Fatal("tool breakpoint must remain when the system prompt is not cached") + } +} + +func TestAnthropicClient_ChatStream_CacheableSystemPrefix(t *testing.T) { + var got anthropicStreamRequest + stream := []string{ + `{"type":"message_start","message":{"usage":{"input_tokens":5}}}`, + `{"type":"content_block_start","content_block":{"type":"text"}}`, + `{"type":"content_block_delta","delta":{"type":"text_delta","text":"ok"}}`, + `{"type":"content_block_stop"}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}`, + `{"type":"message_stop"}`, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + for _, event := range stream { + _, _ = w.Write([]byte("data: " + event + "\n\n")) + w.(http.Flusher).Flush() + } + })) + defer server.Close() + + client := NewAnthropicClientWithBaseURL("test-key", "claude-3-5-sonnet", server.URL, 0) + if err := client.ChatStream(context.Background(), ChatRequest{ + System: "stableCURRENT", + SystemCacheablePrefix: "stable", + Messages: []Message{{Role: "user", Content: "Hi"}}, + Tools: []Tool{{Name: "get_time", Description: "d", InputSchema: map[string]any{"type": "object"}}}, + }, func(StreamEvent) {}); err != nil { + t.Fatalf("ChatStream: %v", err) + } + + blocks, ok := got.System.([]interface{}) + if !ok || len(blocks) != 2 { + t.Fatalf("system = %#v, want two content blocks", got.System) + } + first, _ := blocks[0].(map[string]interface{}) + if cc, _ := first["cache_control"].(map[string]interface{}); cc["type"] != "ephemeral" { + t.Fatalf("first block cache_control = %#v", first["cache_control"]) + } + if got.Tools[0].CacheControl != nil { + t.Fatalf("tool breakpoint must be dropped when the system block is cached: %+v", got.Tools[0]) + } +} diff --git a/internal/ai/providers/provider.go b/internal/ai/providers/provider.go index ac3596741f..86c9d7e13c 100644 --- a/internal/ai/providers/provider.go +++ b/internal/ai/providers/provider.go @@ -107,11 +107,18 @@ type ChatRequest struct { // tokens. Ollama otherwise loads models at its server default (typically // 4096) regardless of the model's trained window and silently truncates // large prompts (#1624). Providers without such a control ignore it. - MinContextTokens int `json:"-"` - System string `json:"system,omitempty"` // System prompt (Anthropic style) - Tools []Tool `json:"tools,omitempty"` // Available tools - ToolChoice *ToolChoice `json:"tool_choice,omitempty"` // nil = model-owned automatic selection; none = text-only safety brake; required = provider-native forced tool use where supported - StreamIdleTimeout time.Duration `json:"-"` // Optional use-case-specific inter-chunk stall allowance; provider request payloads must not serialize it. + MinContextTokens int `json:"-"` + System string `json:"system,omitempty"` // System prompt (Anthropic style) + // SystemCacheablePrefix marks the stable leading portion of System. When + // set and it is an exact prefix of System, providers with prompt-caching + // support (Anthropic) place a cache breakpoint after this prefix so the + // frozen base plus mode text is reused across turns while per-turn content + // appended after it stays outside the cached block. Providers without that + // control ignore it and send System unchanged. + SystemCacheablePrefix string `json:"-"` + Tools []Tool `json:"tools,omitempty"` // Available tools + ToolChoice *ToolChoice `json:"tool_choice,omitempty"` // nil = model-owned automatic selection; none = text-only safety brake; required = provider-native forced tool use where supported + StreamIdleTimeout time.Duration `json:"-"` // Optional use-case-specific inter-chunk stall allowance; provider request payloads must not serialize it. } func (r ChatRequest) NormalizeCollections() ChatRequest { From f08b96b968444be221204e6bb062fecb7a9b3cc4 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:02:04 +0100 Subject: [PATCH 2/3] docs(ai): record prompt-cache transport contract Add the ai-runtime contract section for the Anthropic stable-system-prefix prompt-cache reuse and its focused proof. This completes the contract obligation for the runtime repair in 248ada3cc6 without rewriting that reviewed commit. Change-source: pulse-maintainer --- .../v6/internal/subsystems/ai-runtime.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 3be9c5d891..08b1b42e27 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -877,6 +877,31 @@ outcome requires from current evidence, and explicitly grants no tool choice, mutation, approval, or policy authority. This is the retained-intent seam from cheap local detection into model-owned diagnosis and governed action. +### Anthropic prompt-cache reuse of the stable system prefix (19 September 2026) + +Provider-neutral `ChatRequest.System` stays the full system prompt. The optional +`SystemCacheablePrefix` marks its stable leading portion; the agentic loop +(`internal/ai/chat/agentic_prompt.go`) supplies the frozen base plus execution +mode text there and keeps the per-turn clock and accumulated knowledge after it, +so multi-turn runs can reuse the prompt instead of repaying it every turn +(#2118). When the prefix is an exact prefix of `System`, the Anthropic transport +(`internal/ai/providers/anthropic.go`) serializes `system` as content blocks with +an `ephemeral` cache breakpoint on the stable block and the volatile remainder as +an uncached block; otherwise it sends the plain string form. Anthropic caches in +tools then system then messages order, so the system breakpoint already covers +the tool definitions and the per-tool breakpoint is omitted when the block form +is used; providers without prompt caching ignore the field and send `System` +unchanged. The prefix is applied only when it is an exact prefix of the possibly +sanitized `System`, so a redacted secret cannot be reintroduced through the block +form; a mismatch falls back to the plain string. Cache creation/read counters are +logged at Info for production visibility. Proof: `TestBuildAnthropicSystem`, +`TestAnthropicClient_Chat_CacheableSystemPrefix`, +`TestAnthropicClient_ChatStream_CacheableSystemPrefix`, +`TestAnthropicClient_Chat_KeepsToolBreakpointWithoutCacheableSystem`, and +`TestAgenticLoop_SystemPromptPartsSeparateStablePrefix`. This does not change +model-facing content, tool exposure, or approval authority, and it does not claim +a measured cost reduction or installed-deployment cache-read rate. + ## Canonical Files - `pkg/reporting/evidence.go`: measured retained-history evidence contract for model-facing summaries, with no report narrator or heuristic health judgement. From bc3da3ad49c8ebe150d864d614cdf9b3ea1f5f6f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:02:19 +0100 Subject: [PATCH 3/3] chore(governance): register ai-runtime prompt-cache completion pair Register 248ada3cc6 as completed by f08b96b968, which records the Anthropic stable-system-prefix prompt-cache transport contract, so the reviewed runtime repair is evaluated with its contract as one completion unit. Change-source: pulse-maintainer --- scripts/release_control/canonical_completion_history.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/release_control/canonical_completion_history.json b/scripts/release_control/canonical_completion_history.json index de4e86d920..0fa5be876b 100644 --- a/scripts/release_control/canonical_completion_history.json +++ b/scripts/release_control/canonical_completion_history.json @@ -10,6 +10,11 @@ "incomplete_commit": "e1d97b361edfac311dd57b6695ab3ad9bef613bc", "completion_commit": "087bbf8c20db8ffec1c24037e78a1a88ea4ff69e", "reason": "Evaluate the metrics-store rollup, retry, upsert and auto-vacuum repair together with the follow-up commit that moves its regression proof into the registry-accepted pkg/metrics/store_additional_test.go proof file, as one reviewed completion unit." + }, + { + "incomplete_commit": "248ada3cc662bf0529bbf9586bdfaee6aee2467c", + "completion_commit": "f08b96b968444be221204e6bb062fecb7a9b3cc4", + "reason": "Evaluate the Anthropic stable-system-prefix prompt-cache repair together with the follow-up commit that records its transport contract in docs/release-control/v6/internal/subsystems/ai-runtime.md, as one reviewed completion unit." } ] }