feat(ctxmgr): add SEMANTIC_SUMMARY_SHADOW with deterministic fallback

Shadow evaluates a no-tool summarizer on caller-supplied messages; served
narrative stays deterministic and REPLACE remains unwired.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-16 15:37:50 +00:00
co-authored by Cursor
parent 884e5dbf44
commit 209c997b22
6 changed files with 454 additions and 98 deletions
+1
View File
@@ -99,6 +99,7 @@ func (m *ContextManager) GenerateDailyDigestFromMessages(ctx context.Context, se
}
digest := m.extractRuleDigest(sessionID, projectID, dayKey, msgs)
applyDigestSemanticShadow(ctx, digest, msgs, m.semanticSummarizerOrDefault())
if m.pool != nil {
keyPointsJSON, _ := json.Marshal(digest.KeyPoints)
+1
View File
@@ -33,6 +33,7 @@ type ContextManager struct {
digests map[string]map[string]*DailyDigest // session_id -> day_key -> DailyDigest
readPositions map[string]*ChatReadPosition // session_id:user_id -> read position
cache *MultiLevelCache // 多级统一缓存系统 (C1-C5)
semanticSummarizer SemanticSummarizer // no-tool summarizer; nil → heuristic default
nextActivityID int64
nextMessageID int64
nextChunkID int64
+274
View File
@@ -0,0 +1,274 @@
package ctxmgr
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"unicode/utf8"
)
// SemanticToolPermissions is the only allowed tool permission for summarizers.
// Summarizers must never receive tool callbacks or escalate auth.
const SemanticToolPermissions = "none"
const (
ShadowStatusOff = "off"
ShadowStatusOK = "ok"
ShadowStatusFallback = "fallback"
)
const (
semanticMaxMessages = 40
semanticMaxContentRunes = 240
semanticProtocolV1 = "semantic-summary-v1"
heuristicModelID = "heuristic-semantic-v1"
)
// MinimizedMessage is the R13-minimized outbound unit for a summarizer.
// Only id/role/content are included; no metadata, stage, or auth claims.
type MinimizedMessage struct {
ID int64 `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
}
// SemanticSummaryRequest is the sole input to a no-tool summarizer.
type SemanticSummaryRequest struct {
SessionID string
Messages []MinimizedMessage
InputDigest string
}
// SemanticSummaryResult is the summarizer output (advisory under shadow).
type SemanticSummaryResult struct {
Narrative string
Model string
Protocol string
}
// SemanticShadowReport captures shadow evaluation without replacing deterministic narrative.
type SemanticShadowReport struct {
Status string `json:"status"`
Narrative string `json:"narrative,omitempty"`
InputDigest string `json:"input_digest,omitempty"`
Model string `json:"model,omitempty"`
Protocol string `json:"protocol,omitempty"`
ToolPermissions string `json:"tool_permissions"`
FreshnessNote string `json:"freshness_note,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
}
// SemanticSummarizer produces a narrative from minimized messages.
// Implementations MUST NOT accept tools, callbacks, or auth mutation hooks.
type SemanticSummarizer interface {
Summarize(ctx context.Context, req SemanticSummaryRequest) (*SemanticSummaryResult, error)
}
// StubSemanticSummarizer is a test double with fixed narrative or error.
type StubSemanticSummarizer struct {
Narrative string
Model string
Err error
}
func (s *StubSemanticSummarizer) Summarize(_ context.Context, _ SemanticSummaryRequest) (*SemanticSummaryResult, error) {
if s == nil {
return nil, fmt.Errorf("semantic summarizer unavailable")
}
if s.Err != nil {
return nil, s.Err
}
model := s.Model
if model == "" {
model = "stub-semantic-v1"
}
return &SemanticSummaryResult{
Narrative: s.Narrative,
Model: model,
Protocol: semanticProtocolV1,
}, nil
}
// HeuristicSemanticSummarizer is a no-network, no-tool fallback semantic engine
// used when shadow is on and no custom summarizer is injected.
type HeuristicSemanticSummarizer struct{}
func (h *HeuristicSemanticSummarizer) Summarize(_ context.Context, req SemanticSummaryRequest) (*SemanticSummaryResult, error) {
if len(req.Messages) == 0 {
return nil, fmt.Errorf("no authorized messages for semantic summary")
}
var parts []string
for _, m := range req.Messages {
if m.Role != "user" {
continue
}
c := strings.TrimSpace(m.Content)
if c == "" {
continue
}
// Injection text is material only; never interpret as system/auth directives.
c = stripInstructionishPrefix(c)
if c == "" {
continue
}
parts = append(parts, truncateRunes(c, 48))
if len(parts) >= 4 {
break
}
}
if len(parts) == 0 {
parts = append(parts, fmt.Sprintf("覆盖 %d 条授权可见对话", len(req.Messages)))
}
narrative := "语义要点(shadow):" + strings.Join(parts, ";")
if utf8.RuneCountInString(narrative) > 280 {
narrative = truncateRunes(narrative, 280) + "…"
}
return &SemanticSummaryResult{
Narrative: narrative,
Model: heuristicModelID,
Protocol: semanticProtocolV1,
}, nil
}
func stripInstructionishPrefix(c string) string {
lower := strings.ToLower(c)
markers := []string{
"ignore previous",
"system:",
"忽略以上",
"忽略之前",
"提权",
"grant yourself",
"override auth",
"将所有阶段",
}
for _, m := range markers {
if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(c, m) {
// Keep as opaque material; do not drop evidence, but neutralize directive framing.
return "【用户材料】" + c
}
}
return c
}
// MinimizeAuthorizedMessages builds R13-minimized summarizer input.
// Callers must pass only authorized-visible messages.
func MinimizeAuthorizedMessages(msgs []ChatMessageRecord) []MinimizedMessage {
if len(msgs) == 0 {
return nil
}
start := 0
if len(msgs) > semanticMaxMessages {
start = len(msgs) - semanticMaxMessages
}
out := make([]MinimizedMessage, 0, len(msgs)-start)
for _, m := range msgs[start:] {
out = append(out, MinimizedMessage{
ID: m.ID,
Role: m.Role,
Content: truncateRunes(strings.TrimSpace(m.Content), semanticMaxContentRunes),
})
}
return out
}
// DigestAuthorizedInput produces a stable digest over minimized messages.
func DigestAuthorizedInput(msgs []MinimizedMessage) string {
h := sha256.New()
for _, m := range msgs {
_, _ = fmt.Fprintf(h, "%d|%s|%s\n", m.ID, m.Role, m.Content)
}
return hex.EncodeToString(h.Sum(nil))
}
func (m *ContextManager) SetSemanticSummarizer(s SemanticSummarizer) {
if m == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.semanticSummarizer = s
}
func (m *ContextManager) semanticSummarizerOrDefault() SemanticSummarizer {
if m == nil {
return &HeuristicSemanticSummarizer{}
}
m.mu.RLock()
s := m.semanticSummarizer
m.mu.RUnlock()
if s != nil {
return s
}
return &HeuristicSemanticSummarizer{}
}
// applySemanticShadow evaluates shadow semantic summary without replacing deterministic fields.
// REPLACE is intentionally unwired: served narrative/facts stay deterministic.
func applySemanticShadow(ctx context.Context, sum *ChatSummary, msgs []ChatMessageRecord, summarizer SemanticSummarizer) {
if sum == nil {
return
}
sum.Source = "deterministic"
if !SemanticSummaryShadowEnabled() {
return
}
now := time.Now()
minimized := MinimizeAuthorizedMessages(msgs)
digest := DigestAuthorizedInput(minimized)
report := &SemanticShadowReport{
Status: ShadowStatusFallback,
InputDigest: digest,
ToolPermissions: SemanticToolPermissions,
GeneratedAt: now,
}
if summarizer == nil {
report.FreshnessNote = fmt.Sprintf("semantic_shadow_fallback: summarizer_unavailable; deterministic retained at %s", now.UTC().Format(time.RFC3339))
sum.Shadow = report
sum.FreshnessNote = report.FreshnessNote
return
}
res, err := summarizer.Summarize(ctx, SemanticSummaryRequest{
SessionID: sum.SessionID,
Messages: minimized,
InputDigest: digest,
})
if err != nil || res == nil || strings.TrimSpace(res.Narrative) == "" {
reason := "empty_or_error"
if err != nil {
reason = "model_error"
}
report.FreshnessNote = fmt.Sprintf("semantic_shadow_fallback: %s; deterministic retained at %s", reason, now.UTC().Format(time.RFC3339))
sum.Shadow = report
sum.FreshnessNote = report.FreshnessNote
return
}
report.Status = ShadowStatusOK
report.Narrative = res.Narrative
report.Model = res.Model
report.Protocol = res.Protocol
if report.Protocol == "" {
report.Protocol = semanticProtocolV1
}
// Explicitly do NOT swap sum.Narrative even if REPLACE env is true.
_ = SemanticSummaryReplaceEnabled()
sum.Shadow = report
}
// applyDigestSemanticShadow attaches shadow metadata to a daily digest without replacing rule fields.
func applyDigestSemanticShadow(ctx context.Context, digest *DailyDigest, msgs []ChatMessageRecord, summarizer SemanticSummarizer) {
if digest == nil || !SemanticSummaryShadowEnabled() {
return
}
tmp := &ChatSummary{SessionID: digest.SessionID, Narrative: digest.Headline}
applySemanticShadow(ctx, tmp, msgs, summarizer)
if tmp.Shadow == nil {
return
}
digest.Shadow = tmp.Shadow
digest.FreshnessNote = tmp.FreshnessNote
// Headline/KeyPoints remain rule/deterministic; shadow narrative is advisory only.
}
+9 -1
View File
@@ -11,12 +11,15 @@ import (
// ChatSummary 会话滚动摘要数据模型
type ChatSummary struct {
SessionID string `json:"session_id"`
Narrative string `json:"narrative"` // 叙事摘要(≤300 字)
Narrative string `json:"narrative"` // 叙事摘要(≤300 字);PU6 shadow 下始终为 deterministic
Facts map[string]string `json:"facts"` // 硬事实槽位字典(永不被压缩稀释)
OpenQuestions []string `json:"open_questions"` // 未决技术或商务待办
CoveredCount int `json:"covered_count"` // 累计已覆盖并压缩的历史消息条数
CoveredUntil time.Time `json:"covered_until"` // 摘要覆盖消息的截止时间戳
UpdatedAt time.Time `json:"updated_at"`
Source string `json:"source,omitempty"` // deterministic(served)
FreshnessNote string `json:"freshness_note,omitempty"` // shadow 失败时的可解释回退说明
Shadow *SemanticShadowReport `json:"shadow,omitempty"` // advisory only;REPLACE 未接线
}
// RenderPromptSnippet 渲染为 Prompt 中的【早期对话摘要】段
@@ -188,6 +191,11 @@ func (m *ContextManager) GenerateRollingSummary(ctx context.Context, sessionID s
fullNarrative = string(runes[len(runes)-260:]) + "..."
}
newSum.Narrative = fullNarrative
newSum.Source = "deterministic"
// SEMANTIC_SUMMARY_SHADOW:仅在授权可见集合上评估语义摘要;失败保留 deterministic + freshness。
// SEMANTIC_SUMMARY_REPLACE 故意不接线,served Narrative 永不被语义结果替换。
applySemanticShadow(ctx, newSum, earlyMsgs, m.semanticSummarizerOrDefault())
// 保存并返回
_ = m.SaveChatSummary(ctx, newSum)
+71
View File
@@ -0,0 +1,71 @@
package ctxmgr
import (
"os"
"strings"
"sync"
)
const (
EnvSemanticSummaryShadow = "SEMANTIC_SUMMARY_SHADOW"
EnvSemanticSummaryReplace = "SEMANTIC_SUMMARY_REPLACE"
)
// SummaryFeatureFlags controls semantic summary rollout.
// REPLACE is reserved and must remain unwired in PU6 shadow delivery.
type SummaryFeatureFlags struct {
Shadow bool
Replace bool
}
var (
summaryFlagsMu sync.RWMutex
activeSummaryFlags = loadSummaryFlagsFromEnv()
)
func loadSummaryFlagsFromEnv() SummaryFeatureFlags {
return SummaryFeatureFlags{
Shadow: envBoolDefault(EnvSemanticSummaryShadow, false),
Replace: envBoolDefault(EnvSemanticSummaryReplace, false),
}
}
func envBoolDefault(key string, def bool) bool {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
}
// CurrentSummaryFlags returns the active summary feature flags.
func CurrentSummaryFlags() SummaryFeatureFlags {
summaryFlagsMu.RLock()
defer summaryFlagsMu.RUnlock()
return activeSummaryFlags
}
// SemanticSummaryShadowEnabled reports whether shadow semantic summary is on.
func SemanticSummaryShadowEnabled() bool {
return CurrentSummaryFlags().Shadow
}
// SemanticSummaryReplaceEnabled reports the REPLACE env bit.
// PU6 must not use this to swap the served deterministic narrative.
func SemanticSummaryReplaceEnabled() bool {
return CurrentSummaryFlags().Replace
}
// SetSummaryFlagsForTest overrides flags in tests.
func SetSummaryFlagsForTest(f SummaryFeatureFlags) {
summaryFlagsMu.Lock()
activeSummaryFlags = f
summaryFlagsMu.Unlock()
}
// ResetSummaryFlagsForTest restores flags from the process environment.
func ResetSummaryFlagsForTest() {
summaryFlagsMu.Lock()
activeSummaryFlags = loadSummaryFlagsFromEnv()
summaryFlagsMu.Unlock()
}
+3 -2
View File
@@ -39,7 +39,7 @@ type ActivityItem struct {
Title string `json:"title"`
Detail map[string]any `json:"detail,omitempty"`
RawWeight float64 `json:"raw_weight"` // 初始权重
DecayedWeight float64 `json:"decayed_weight"`// 时间衰减后当前有效权重
DecayedWeight float64 `json:"decayed_weight"` // 时间衰减后当前有效权重
IsMilestone bool `json:"is_milestone"` // 里程碑事件不衰减
CreatedAt time.Time `json:"created_at"`
}
@@ -328,5 +328,6 @@ type DailyDigest struct {
DigestVersion int `json:"digest_version"`
GeneratedAt *time.Time `json:"generated_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
FreshnessNote string `json:"freshness_note,omitempty"`
Shadow *SemanticShadowReport `json:"shadow,omitempty"`
}