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 }