Files
Agent-System/internal/ctxmgr/semantic_summary.go
T
weichenandCursor 209c997b22 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>
2026-09-16 15:37:50 +00:00

275 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
}