Merge reviewed candidate 20260919T200006Z-core-runtime (pulse bc3da3ad49)

Integrate the reviewed ai-runtime prompt-cache repair (248ada3cc6) with its transport contract (f08b96b968) and registered completion pair (bc3da3ad49). Merge resolved the canonical completion registry against the rehearsal-timeout completion pair already on main by keeping both entries.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-19 22:20:35 +01:00
8 changed files with 326 additions and 22 deletions
@@ -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.
+11 -7
View File
@@ -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)
+13 -2
View File
@@ -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
}
+24
View File
@@ -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{})
+68 -8
View File
@@ -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.
+168
View File
@@ -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])
}
}
+12 -5
View File
@@ -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 {
@@ -15,6 +15,11 @@
"incomplete_commit": "d0f138cf267184260ad01fe517f7610c7769255c",
"completion_commit": "0f9f33777c4a717a82d2fefee6c30f79d5b405e2",
"reason": "Evaluate the rehearsal backend package-timeout runtime change together with the follow-up commit that records its deployment-installability contract section as one completion unit. The completion tree passes the canonical completion guard for the combined changed files."
},
{
"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."
}
]
}