Files
CyberStrikeAI/internal/multiagent/eino_agentic_summarize.go
T
2026-08-15 01:39:58 +08:00

279 lines
10 KiB
Go

package multiagent
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// newEinoAgenticSummarizationMiddleware wires the project's domain-specific
// compaction policy into Eino's native typed AgenticMessage summarization.
func newEinoAgenticSummarizationMiddleware(
ctx context.Context,
summaryModel model.BaseModel[*schema.AgenticMessage],
appCfg *config.Config,
mwCfg *config.MultiAgentEinoMiddlewareConfig,
conversationID string,
db *database.DB,
projectID string,
logger *zap.Logger,
) (adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage], error) {
if summaryModel == nil || appCfg == nil {
return nil, fmt.Errorf("multiagent: agentic summarization 需要 model 与配置")
}
maxTotal := appCfg.OpenAI.MaxTotalTokens
if maxTotal <= 0 {
maxTotal = 120000
}
triggerRatio := 0.8
emitInternalEvents := true
outputReserve := config.DefaultSummarizationOutputReserveTokens
userLedgerMaxRunes := config.DefaultSummarizationUserIntentLedgerMaxRunes
userLedgerEntryMaxRunes := config.DefaultSummarizationUserIntentLedgerEntryMaxRunes
toolMaxBytes := config.MultiAgentEinoMiddlewareConfig{}.ReductionMaxLengthForTruncEffective()
if mwCfg != nil {
triggerRatio = mwCfg.SummarizationTriggerRatioEffective()
emitInternalEvents = mwCfg.SummarizationEmitInternalEventsEffective()
outputReserve = mwCfg.SummarizationOutputReserveTokensEffective()
userLedgerMaxRunes = mwCfg.SummarizationUserIntentLedgerMaxRunesEffective()
userLedgerEntryMaxRunes = mwCfg.SummarizationUserIntentLedgerEntryMaxRunesEffective()
toolMaxBytes = mwCfg.ReductionMaxLengthForTruncEffective()
}
ledgerWindowCap := modelFacingRuneBudget(maxTotal, 0.20)
userLedgerMaxRunes = minPositiveInt(userLedgerMaxRunes, ledgerWindowCap)
userLedgerEntryMaxRunes = minPositiveInt(userLedgerEntryMaxRunes, userLedgerMaxRunes)
trigger := int(float64(maxTotal) * triggerRatio)
if trigger < 4096 {
trigger = maxTotal
if trigger < 4096 {
trigger = 4096
}
}
modelName := strings.TrimSpace(appCfg.OpenAI.Model)
if modelName == "" {
modelName = "gpt-4o"
}
classicTokenCounter := einoSummarizationTokenCounter(modelName)
agenticTokenCounter := func(ctx context.Context, input *summarization.TypedTokenCounterInput[*schema.AgenticMessage]) (int, error) {
if input == nil {
return 0, nil
}
return classicTokenCounter(ctx, &summarization.TokenCounterInput{
Messages: AgenticMessagesToEino(input.Messages),
Tools: input.Tools,
})
}
recentTrailMax := trigger / 4
if recentTrailMax < 2048 {
recentTrailMax = 2048
}
if recentTrailMax > trigger/2 {
recentTrailMax = trigger / 2
}
summaryInputMax := trigger - outputReserve
if summaryInputMax < 4096 {
summaryInputMax = trigger * 80 / 100
}
if summaryInputMax < 4096 {
summaryInputMax = 4096
}
transcriptPath := ""
if conv := strings.TrimSpace(conversationID); conv != "" {
baseRoot := filepath.Join(os.TempDir(), "cyberstrike-summarization")
if dbPath := strings.TrimSpace(appCfg.Database.Path); dbPath != "" {
baseRoot = filepath.Join(filepath.Dir(dbPath), "conversation_artifacts", sanitizeEinoPathSegment(conv), "summarization")
}
base := baseRoot
if abs, err := filepath.Abs(base); err == nil {
base = abs
}
if mkErr := os.MkdirAll(base, 0o755); mkErr == nil {
transcriptPath = filepath.Join(base, "transcript.txt")
}
}
retryPolicy := einoTransientRunRetryPolicyFromMW(mwCfg)
retryMax := retryPolicy.maxAttempts
var summaryOverflowRetries int
summaryModelOpts := []model.Option{
einoopenai.WithMaxCompletionTokens(outputReserve),
}
mw, err := summarization.NewTyped[*schema.AgenticMessage](ctx, &summarization.TypedConfig[*schema.AgenticMessage]{
Model: summaryModel,
ModelOptions: summaryModelOpts,
GenModelInput: func(ctx context.Context, sysInstruction, userInstruction *schema.AgenticMessage, originalMsgs []*schema.AgenticMessage) ([]*schema.AgenticMessage, error) {
classicOriginal := AgenticMessagesToEino(originalMsgs)
if transcriptPath != "" && len(classicOriginal) > 0 {
if werr := writeSummarizationTranscript(transcriptPath, classicOriginal); werr != nil && logger != nil {
logger.Warn("eino agentic summarization transcript preflight 写入失败",
zap.String("path", transcriptPath), zap.Error(werr))
}
}
budget := summaryInputMax
aggressive := summaryOverflowRetries > 0
if aggressive {
budget = summaryInputMax * 70 / 100
if budget < 4096 {
budget = 4096
}
}
input, dropped, berr := buildBudgetedSummarizationModelInput(
ctx,
agenticInstructionToClassic(sysInstruction, schema.System),
agenticInstructionToClassic(userInstruction, schema.User),
classicOriginal,
classicTokenCounter,
budget,
summarizationInputBudgetOpts{
toolMaxBytes: toolMaxBytes,
spillRef: transcriptPath,
aggressive: aggressive,
},
)
if logger != nil && (berr != nil || dropped > 0 || aggressive) {
fields := []zap.Field{
zap.Int("max_input_tokens", budget),
zap.Int("trigger_context_tokens", trigger),
zap.Int("output_reserve_tokens", outputReserve),
zap.Int("dropped_rounds", dropped),
zap.Bool("aggressive", aggressive),
}
if berr != nil {
fields = append(fields, zap.Error(berr))
logger.Warn("eino agentic summarization input budget failed", fields...)
} else {
logger.Info("eino agentic summarization input bounded", fields...)
}
}
return EinoMessagesToAgentic(input), berr
},
Trigger: &summarization.TriggerCondition{
ContextTokens: trigger,
},
TokenCounter: agenticTokenCounter,
UserInstruction: einoSummarizeUserInstruction,
EmitInternalEvents: emitInternalEvents,
TranscriptFilePath: transcriptPath,
Retry: &summarization.TypedRetryConfig[*schema.AgenticMessage]{
MaxRetries: &retryMax,
ShouldRetry: func(_ context.Context, _ *schema.AgenticMessage, err error) bool {
if isEinoContextOverflowError(err) && summaryOverflowRetries < 1 {
summaryOverflowRetries++
if logger != nil {
logger.Warn("eino agentic summarization context overflow, retrying with aggressive compaction",
zap.Error(err),
)
}
return true
}
retry := isEinoTransientRunError(err)
if retry && logger != nil {
logger.Warn("eino agentic summarization generate transient error, will retry if attempts remain",
zap.Error(err),
zap.Int("max_retries", retryMax),
)
}
return retry
},
},
Finalize: func(ctx context.Context, originalMessages []*schema.AgenticMessage, summary *schema.AgenticMessage) ([]*schema.AgenticMessage, error) {
classicOriginal := AgenticMessagesToEino(originalMessages)
classicSummary := agenticSummaryToClassicMessage(summary)
if classicSummary == nil {
return nil, fmt.Errorf("agentic summarization returned empty summary")
}
compactionMessages := stripOriginalUserIntentLedgerFromMessages(classicOriginal)
defaultFinalized, derr := summarization.DefaultFinalize(ctx, compactionMessages, classicSummary)
if derr != nil {
return nil, derr
}
if len(defaultFinalized) == 0 {
return nil, fmt.Errorf("agentic summarization default finalize returned no messages")
}
summaryMsg := appendTranscriptPathToSummarizationMessage(defaultFinalized[len(defaultFinalized)-1], transcriptPath)
summaryMsg = stripAnalysisFromSummarizationMessage(summaryMsg)
userLedger := buildOriginalUserIntentLedgerMessage(classicOriginal, userLedgerMaxRunes, userLedgerEntryMaxRunes)
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, compactionMessages, summaryMsg, classicTokenCounter, recentTrailMax)
if ferr != nil {
return nil, ferr
}
out = mergeMessageIntoLeadingSystem(out, userLedger)
if appCfg != nil {
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
}
return EinoMessagesToAgentic(out), nil
},
Callback: func(ctx context.Context, before, after adk.TypedChatModelAgentState[*schema.AgenticMessage]) error {
classicBefore := AgenticMessagesToEino(before.Messages)
classicAfter := AgenticMessagesToEino(after.Messages)
if transcriptPath != "" && len(classicBefore) > 0 {
if werr := writeSummarizationTranscript(transcriptPath, classicBefore); werr != nil && logger != nil {
logger.Warn("eino agentic summarization transcript 写入失败",
zap.String("path", transcriptPath),
zap.Error(werr),
)
}
}
if logger != nil {
beforeTokens, _ := classicTokenCounter(ctx, &summarization.TokenCounterInput{Messages: classicBefore})
afterTokens, _ := classicTokenCounter(ctx, &summarization.TokenCounterInput{Messages: classicAfter})
logger.Info("eino agentic summarization 已压缩上下文",
zap.Int("messages_before", len(before.Messages)),
zap.Int("messages_after", len(after.Messages)),
zap.Int("tokens_before_estimated", beforeTokens),
zap.Int("tokens_after_estimated", afterTokens),
zap.Int("max_total_tokens", maxTotal),
zap.Int("trigger_context_tokens", trigger),
zap.String("transcript_file", transcriptPath),
)
}
return nil
},
})
if err != nil {
return nil, fmt.Errorf("summarization.NewTyped[AgenticMessage]: %w", err)
}
return mw, nil
}
func agenticInstructionToClassic(msg *schema.AgenticMessage, fallbackRole schema.RoleType) *schema.Message {
msgs := AgenticMessageToEino(msg)
if len(msgs) > 0 && msgs[0] != nil {
return msgs[0]
}
return &schema.Message{Role: fallbackRole}
}
func agenticSummaryToClassicMessage(msg *schema.AgenticMessage) *schema.Message {
msgs := AgenticMessageToEino(msg)
for _, m := range msgs {
if m == nil {
continue
}
if m.Role == schema.Assistant || strings.TrimSpace(m.Content) != "" || m.ReasoningContent != "" {
if m.Role != schema.Assistant {
cp := *m
cp.Role = schema.Assistant
return &cp
}
return m
}
}
return nil
}