diff --git a/internal/multiagent/eino_single_runner.go b/internal/multiagent/eino_single_runner.go index 198b4a7c..1eaff65c 100644 --- a/internal/multiagent/eino_single_runner.go +++ b/internal/multiagent/eino_single_runner.go @@ -50,6 +50,7 @@ func RunEinoSingleChatModelAgent( if ma == nil { return nil, fmt.Errorf("eino single: multi_agent 配置为空") } + runtimeUserMessage := prepareLatestUserMessageForModel(userMessage, appCfg, &ma.EinoMiddleware, conversationID, logger) einoLoc, einoSkillMW, einoFSTools, skillsRoot, einoErr := prepareEinoSkills(ctx, appCfg.SkillsDir, ma, logger) if einoErr != nil { @@ -201,7 +202,7 @@ func RunEinoSingleChatModelAgent( } baseMsgs := historyToMessages(history, appCfg, &ma.EinoMiddleware) - baseMsgs = appendUserMessageIfNeeded(baseMsgs, userMessage) + baseMsgs = appendUserMessageIfNeeded(baseMsgs, runtimeUserMessage) streamsMainAssistant := func(agent string) bool { return agent == "" || agent == einoSingleAgentName diff --git a/internal/multiagent/eino_summarize.go b/internal/multiagent/eino_summarize.go index 1abc79f6..eee3589c 100644 --- a/internal/multiagent/eino_summarize.go +++ b/internal/multiagent/eino_summarize.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "time" "cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/config" @@ -14,11 +15,11 @@ import ( "cyberstrike-ai/internal/project" "github.com/bytedance/sonic" + 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" - einoopenai "github.com/cloudwego/eino-ext/components/model/openai" "go.uber.org/zap" ) @@ -98,9 +99,13 @@ func newEinoSummarizationMiddleware( } triggerRatio := 0.8 emitInternalEvents := true + userLedgerMaxRunes := config.DefaultSummarizationUserIntentLedgerMaxRunes + userLedgerEntryMaxRunes := config.DefaultSummarizationUserIntentLedgerEntryMaxRunes if mwCfg != nil { triggerRatio = mwCfg.SummarizationTriggerRatioEffective() emitInternalEvents = mwCfg.SummarizationEmitInternalEventsEffective() + userLedgerMaxRunes = mwCfg.SummarizationUserIntentLedgerMaxRunesEffective() + userLedgerEntryMaxRunes = mwCfg.SummarizationUserIntentLedgerEntryMaxRunesEffective() } // Keep enough safety margin for tokenizer/model-side accounting mismatch. trigger := int(float64(maxTotal) * triggerRatio) @@ -190,10 +195,13 @@ func newEinoSummarizationMiddleware( }, Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) { summary = stripAnalysisFromSummarizationMessage(summary) - out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax) + userLedger := buildOriginalUserIntentLedgerMessageFromStore(db, conversationID, originalMessages, userLedgerMaxRunes, userLedgerEntryMaxRunes, logger) + compactionMessages := stripOriginalUserIntentLedgerFromMessages(originalMessages) + out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, compactionMessages, summary, tokenCounter, recentTrailMax) if ferr != nil { return nil, ferr } + out = mergeMessageIntoLeadingSystem(out, userLedger) if appCfg != nil { out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger) } @@ -230,6 +238,61 @@ func newEinoSummarizationMiddleware( return mw, nil } +func buildOriginalUserIntentLedgerMessageFromStore( + db *database.DB, + conversationID string, + fallbackMessages []adk.Message, + maxRunes int, + entryMaxRunes int, + logger *zap.Logger, +) adk.Message { + conversationID = strings.TrimSpace(conversationID) + if db == nil || conversationID == "" { + return buildOriginalUserIntentLedgerMessage(fallbackMessages, maxRunes, entryMaxRunes) + } + msgs, err := db.GetMessages(conversationID) + if err != nil { + if logger != nil { + logger.Warn("summarization: 从数据库读取原始用户消息失败,回退到当前上下文", + zap.String("conversationId", conversationID), + zap.Error(err), + ) + } + return buildOriginalUserIntentLedgerMessage(fallbackMessages, maxRunes, entryMaxRunes) + } + userMsgs := make([]adk.Message, 0, len(msgs)) + for _, msg := range msgs { + if strings.ToLower(strings.TrimSpace(msg.Role)) != "user" { + continue + } + content := strings.TrimSpace(msg.Content) + if content == "" { + continue + } + userMsgs = append(userMsgs, schema.UserMessage(formatStoredUserLedgerText(msg))) + } + if len(userMsgs) == 0 { + return buildOriginalUserIntentLedgerMessage(fallbackMessages, maxRunes, entryMaxRunes) + } + return buildOriginalUserIntentLedgerMessage(userMsgs, maxRunes, entryMaxRunes) +} + +func formatStoredUserLedgerText(msg database.Message) string { + var parts []string + if id := strings.TrimSpace(msg.ID); id != "" { + parts = append(parts, "message_id="+id) + } + if !msg.CreatedAt.IsZero() { + parts = append(parts, "created_at="+msg.CreatedAt.Format(time.RFC3339Nano)) + } + meta := strings.Join(parts, " ") + content := strings.TrimSpace(msg.Content) + if meta == "" { + return content + } + return meta + "\n" + content +} + // refreshFactIndexInMessages 在 summarization 压缩后,用 DB 最新索引替换 system 中已有的项目黑板索引段。 func refreshFactIndexInMessages(msgs []adk.Message, db *database.DB, projectID string, cfg config.ProjectConfig, logger *zap.Logger) []adk.Message { if db == nil || !cfg.Enabled { diff --git a/internal/multiagent/eino_summarize_output.go b/internal/multiagent/eino_summarize_output.go index 9f96b975..079b5097 100644 --- a/internal/multiagent/eino_summarize_output.go +++ b/internal/multiagent/eino_summarize_output.go @@ -1,6 +1,7 @@ package multiagent import ( + "fmt" "regexp" "strings" @@ -11,6 +12,13 @@ import ( var ( summarizationAnalysisBlockRegex = regexp.MustCompile(`(?is)\s*.*?\s*`) summarizationSummaryBlockRegex = regexp.MustCompile(`(?is)\s*(.*?)\s*`) + userIntentLedgerBlockRegex = regexp.MustCompile(`(?is)\s*(.*?)\s*`) + userIntentLedgerSectionRegex = regexp.MustCompile(`(?is)\s*## 原始用户输入与约束账本(系统保真)\s*\s*.*?\s*\s*`) +) + +const ( + userIntentLedgerStartMarker = "" + userIntentLedgerEndMarker = "" ) // stripAnalysisFromSummarizationMessage removes the block from a post-processed @@ -71,3 +79,212 @@ func extractSummarizationSummaryBody(text string) (string, bool) { } return body, true } + +// buildOriginalUserIntentLedgerMessage returns a deterministic, host-generated +// context anchor for raw user inputs. Keep it separate from the model-generated +// working summary so compaction can rewrite the summary without rewriting user intent. +func buildOriginalUserIntentLedgerMessage(originalMessages []adk.Message, maxRunes, entryMaxRunes int) adk.Message { + ledger := buildOriginalUserIntentLedger(originalMessages, maxRunes, entryMaxRunes) + if strings.TrimSpace(ledger) == "" { + return nil + } + return schema.SystemMessage(wrapUserIntentLedger(ledger)) +} + +func mergeMessageIntoLeadingSystem(msgs []adk.Message, msg adk.Message) []adk.Message { + if msg == nil { + return msgs + } + for i, existing := range msgs { + if existing == nil || existing.Role != schema.System { + continue + } + cloned := *existing + cloned.Content = strings.TrimSpace(strings.TrimSpace(cloned.Content) + "\n\n" + strings.TrimSpace(msg.Content)) + out := make([]adk.Message, len(msgs)) + copy(out, msgs) + out[i] = &cloned + return out + } + out := make([]adk.Message, 0, len(msgs)+1) + out = append(out, msg) + out = append(out, msgs...) + return out +} + +func stripOriginalUserIntentLedgerFromMessages(msgs []adk.Message) []adk.Message { + if len(msgs) == 0 { + return msgs + } + out := make([]adk.Message, 0, len(msgs)) + for _, msg := range msgs { + if msg == nil { + continue + } + cloned := *msg + cloned.Content = stripOriginalUserIntentLedgerFromText(cloned.Content) + if len(cloned.UserInputMultiContent) > 0 { + parts := make([]schema.MessageInputPart, len(cloned.UserInputMultiContent)) + copy(parts, cloned.UserInputMultiContent) + for i := range parts { + if parts[i].Type == schema.ChatMessagePartTypeText { + parts[i].Text = stripOriginalUserIntentLedgerFromText(parts[i].Text) + } + } + cloned.UserInputMultiContent = parts + } + if strings.TrimSpace(cloned.Content) == "" && len(cloned.UserInputMultiContent) == 0 && len(cloned.ToolCalls) == 0 && cloned.ReasoningContent == "" { + continue + } + out = append(out, &cloned) + } + return out +} + +func buildOriginalUserIntentLedger(msgs []adk.Message, maxRunes, entryMaxRunes int) string { + entries := collectOriginalUserIntentEntries(msgs) + if len(entries) == 0 { + return "" + } + var sb strings.Builder + for i, entry := range entries { + line := fmt.Sprintf("- [U%03d] %s\n", i+1, sanitizeUserIntentLedgerEntry(entry, entryMaxRunes)) + if maxRunes > 0 && utf8RuneLen(sb.String())+utf8RuneLen(line) > maxRunes { + sb.WriteString("- [...truncated] 用户原始输入账本超过预算;完整压缩前记录见 summarization transcript。\n") + break + } + sb.WriteString(line) + } + return strings.TrimSpace(sb.String()) +} + +func collectOriginalUserIntentEntries(msgs []adk.Message) []string { + seen := make(map[string]struct{}) + entries := make([]string, 0, 16) + add := func(s string) { + s = strings.TrimSpace(s) + if s == "" || isSyntheticContinuationUserText(s) { + return + } + key := normalizeUserIntentLedgerText(s) + if key == "" { + return + } + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + entries = append(entries, s) + } + for _, msg := range msgs { + if msg == nil { + continue + } + for _, entry := range extractExistingUserIntentLedgerEntries(messageTextForLedger(msg)) { + add(entry) + } + if msg.Role == schema.User { + add(adkUserMessageText(msg)) + } + } + return entries +} + +func messageTextForLedger(msg adk.Message) string { + if msg == nil { + return "" + } + var b strings.Builder + if strings.TrimSpace(msg.Content) != "" { + b.WriteString(msg.Content) + } + for _, part := range msg.UserInputMultiContent { + if part.Type != schema.ChatMessagePartTypeText || strings.TrimSpace(part.Text) == "" { + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(part.Text) + } + return b.String() +} + +func extractExistingUserIntentLedgerEntries(text string) []string { + matches := userIntentLedgerBlockRegex.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return nil + } + out := make([]string, 0, len(matches)) + for _, match := range matches { + if len(match) < 2 { + continue + } + for _, line := range strings.Split(match[1], "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "- [...truncated]") { + continue + } + if strings.HasPrefix(line, "- [U") { + if idx := strings.Index(line, "]"); idx >= 0 && idx+1 < len(line) { + line = strings.TrimSpace(line[idx+1:]) + } + } + if line != "" { + out = append(out, line) + } + } + } + return out +} + +func stripOriginalUserIntentLedgerFromText(text string) string { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + text = userIntentLedgerSectionRegex.ReplaceAllString(text, "\n") + text = userIntentLedgerBlockRegex.ReplaceAllString(text, "\n") + return strings.TrimSpace(text) +} + +func wrapUserIntentLedger(ledger string) string { + return strings.TrimSpace("## 原始用户输入与约束账本(系统保真)\n" + + userIntentLedgerStartMarker + "\n" + + strings.TrimSpace(ledger) + "\n" + + userIntentLedgerEndMarker) +} + +func sanitizeUserIntentLedgerEntry(s string, maxRunes int) string { + s = strings.ReplaceAll(strings.TrimSpace(s), userIntentLedgerStartMarker, "[ledger-start]") + s = strings.ReplaceAll(s, userIntentLedgerEndMarker, "[ledger-end]") + s = strings.Join(strings.Fields(s), " ") + if maxRunes > 0 && utf8RuneLen(s) > maxRunes { + return truncateUserIntentLedgerRunes(s, maxRunes) + "..." + } + return s +} + +func normalizeUserIntentLedgerText(s string) string { + return strings.Join(strings.Fields(strings.TrimSpace(s)), " ") +} + +func isSyntheticContinuationUserText(s string) bool { + return strings.Contains(s, continuationSessionMarker) || + strings.Contains(s, "【系统自动续跑 / Auto resume】") +} + +func utf8RuneLen(s string) int { + return len([]rune(s)) +} + +func truncateUserIntentLedgerRunes(s string, n int) string { + if n <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) +} diff --git a/internal/multiagent/eino_summarize_output_test.go b/internal/multiagent/eino_summarize_output_test.go index 47abc91e..6c8ddd0c 100644 --- a/internal/multiagent/eino_summarize_output_test.go +++ b/internal/multiagent/eino_summarize_output_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/schema" ) @@ -65,3 +66,110 @@ func TestStripAnalysisFromSummarizationText_NoAnalysisUnchanged(t *testing.T) { t.Fatalf("expected unchanged text, got %q", got) } } + +func TestBuildOriginalUserIntentLedgerMessage_AppendsRawUserMessages(t *testing.T) { + original := []adk.Message{ + schema.UserMessage("第一轮:只测 staging,不要碰 prod。"), + schema.AssistantMessage("ok", nil), + schema.UserMessage("第二轮:优先验证 /api/login 的 SQL 注入。"), + schema.UserMessage(FormatEmptyResponseContinueUserMessage()), + } + + out := buildOriginalUserIntentLedgerMessage(original, 96000, 16000) + if out == nil { + t.Fatal("ledger message should be non-nil") + } + if out.Role != schema.System { + t.Fatalf("ledger should be a system anchor, got %s", out.Role) + } + body := out.Content + if !strings.Contains(body, userIntentLedgerStartMarker) || !strings.Contains(body, userIntentLedgerEndMarker) { + t.Fatalf("ledger markers missing: %q", body) + } + if !strings.Contains(body, "只测 staging,不要碰 prod") { + t.Fatalf("first user constraint missing: %q", body) + } + if !strings.Contains(body, "优先验证 /api/login") { + t.Fatalf("second user request missing: %q", body) + } + if strings.Contains(body, "系统自动续跑") { + t.Fatalf("synthetic auto-resume user message should be skipped: %q", body) + } +} + +func TestBuildOriginalUserIntentLedgerMessage_CarriesPreviousLedgerAndDedups(t *testing.T) { + prevSummary := schema.AssistantMessage(wrapUserIntentLedger("- [U001] 原始目标:example.com\n- [U002] 禁止高危破坏性操作"), nil) + original := []adk.Message{ + prevSummary, + schema.UserMessage("禁止高危破坏性操作"), + schema.UserMessage("新增约束:只输出中文报告"), + } + + out := buildOriginalUserIntentLedgerMessage(original, 96000, 16000) + body := out.Content + if !strings.Contains(body, "原始目标:example.com") || !strings.Contains(body, "新增约束:只输出中文报告") { + t.Fatalf("ledger did not carry old and new entries: %q", body) + } + if strings.Count(body, "禁止高危破坏性操作") != 1 { + t.Fatalf("duplicate ledger entry was not deduped: %q", body) + } + if strings.Count(body, userIntentLedgerStartMarker) != 1 { + t.Fatalf("expected one ledger block: %q", body) + } +} + +func TestStripOriginalUserIntentLedgerFromMessages_RemovesOldLedgerBeforeRebuild(t *testing.T) { + msgs := []adk.Message{ + schema.SystemMessage("sys\n\n" + wrapUserIntentLedger("- [U001] old goal")), + schema.AssistantMessage("summary\n\n"+wrapUserIntentLedger("- [U001] old goal"), nil), + } + + out := stripOriginalUserIntentLedgerFromMessages(msgs) + if len(out) != 2 { + t.Fatalf("expected 2 messages, got %d", len(out)) + } + for _, msg := range out { + if strings.Contains(msg.Content, userIntentLedgerStartMarker) || strings.Contains(msg.Content, "old goal") { + t.Fatalf("old ledger leaked after strip: %q", msg.Content) + } + } + if out[0].Content != "sys" { + t.Fatalf("non-ledger system content should remain: %q", out[0].Content) + } + if out[1].Content != "summary" { + t.Fatalf("non-ledger summary content should remain: %q", out[1].Content) + } +} + +func TestMergeMessageIntoLeadingSystem_KeepsLedgerSeparateFromSummary(t *testing.T) { + sys := schema.SystemMessage("sys") + ledger := schema.SystemMessage(wrapUserIntentLedger("- [U001] goal")) + summary := schema.AssistantMessage("work state", nil) + + out := mergeMessageIntoLeadingSystem([]adk.Message{sys, summary}, ledger) + if len(out) != 2 { + t.Fatalf("expected 2 messages, got %d", len(out)) + } + if out[0].Role != schema.System || !strings.Contains(out[0].Content, "sys") || !strings.Contains(out[0].Content, userIntentLedgerStartMarker) { + t.Fatalf("ledger should be merged into leading system: %+v", out[0]) + } + if out[1] != summary { + t.Fatalf("summary should remain second message") + } + if strings.Contains(out[1].Content, userIntentLedgerStartMarker) { + t.Fatalf("summary should not carry ledger: %q", out[1].Content) + } +} + +func TestMergeMessageIntoLeadingSystem_NoSystemPrependsLedger(t *testing.T) { + ledger := schema.SystemMessage(wrapUserIntentLedger("- [U001] goal")) + summary := schema.AssistantMessage("work state", nil) + + out := mergeMessageIntoLeadingSystem([]adk.Message{summary}, ledger) + if len(out) != 2 { + t.Fatalf("expected 2 messages, got %d", len(out)) + } + if out[0] != ledger || out[1] != summary { + t.Fatalf("ledger should be prepended when no system exists: %+v", out) + } +} diff --git a/internal/multiagent/eino_summarize_test.go b/internal/multiagent/eino_summarize_test.go index 0a1f6290..13479d0d 100644 --- a/internal/multiagent/eino_summarize_test.go +++ b/internal/multiagent/eino_summarize_test.go @@ -498,3 +498,47 @@ func TestRefreshFactIndexInMessages(t *testing.T) { t.Fatalf("non-index system content should be preserved: %q", sys) } } + +func TestBuildOriginalUserIntentLedgerMessageFromStore_UsesDatabaseAsCanonicalSource(t *testing.T) { + t.Parallel() + dbPath := filepath.Join(t.TempDir(), "summarize-user-ledger.db") + db, err := database.NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + conv, err := db.CreateConversation("ledger", database.ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + stored, err := db.AddMessage(conv.ID, "user", "原始用户输入:只测 staging,不要碰 prod。", nil) + if err != nil { + t.Fatal(err) + } + if _, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil); err != nil { + t.Fatal(err) + } + + ledger := buildOriginalUserIntentLedgerMessageFromStore( + db, + conv.ID, + []adk.Message{schema.UserMessage("内存里的派生/续跑消息,不应作为权威源。")}, + config.DefaultSummarizationUserIntentLedgerMaxRunes, + config.DefaultSummarizationUserIntentLedgerEntryMaxRunes, + zap.NewNop(), + ) + if ledger == nil { + t.Fatal("expected ledger message") + } + body := ledger.Content + if !strings.Contains(body, stored.ID) { + t.Fatalf("ledger should include canonical DB message id %q: %q", stored.ID, body) + } + if !strings.Contains(body, "只测 staging,不要碰 prod") { + t.Fatalf("ledger should include stored user content: %q", body) + } + if strings.Contains(body, "内存里的派生") { + t.Fatalf("fallback in-memory user message should not be used when DB has user messages: %q", body) + } +} diff --git a/internal/multiagent/latest_user_message.go b/internal/multiagent/latest_user_message.go new file mode 100644 index 00000000..de7a33c9 --- /dev/null +++ b/internal/multiagent/latest_user_message.go @@ -0,0 +1,137 @@ +package multiagent + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "cyberstrike-ai/internal/config" + + "go.uber.org/zap" +) + +func prepareLatestUserMessageForModel(userMessage string, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig, conversationID string, logger *zap.Logger) string { + if strings.TrimSpace(userMessage) == "" { + return strings.TrimSpace(userMessage) + } + if mwCfg == nil { + var zero config.MultiAgentEinoMiddlewareConfig + mwCfg = &zero + } + maxRunes := mwCfg.LatestUserMessageMaxRunesEffective() + if maxRunes <= 0 || utf8RuneLen(userMessage) <= maxRunes { + return userMessage + } + + headRunes, tailRunes := normalizeLatestUserPreviewBudget( + maxRunes, + mwCfg.LatestUserMessageHeadRunesEffective(), + mwCfg.LatestUserMessageTailRunesEffective(), + ) + head, tail := splitHeadTailRunes(userMessage, headRunes, tailRunes) + artifactPath, writeErr := persistLatestUserMessageArtifact(userMessage, appCfg, conversationID) + if writeErr != nil && logger != nil { + logger.Warn("latest user message artifact 写入失败,将仅使用预览", + zap.String("conversationId", conversationID), + zap.Error(writeErr), + ) + } + + var sb strings.Builder + sb.WriteString("【系统提示:本轮用户输入过长,已为模型上下文生成裁剪预览。】\n") + sb.WriteString("用户原始输入已完整保存到数据库 messages.role=user;") + if artifactPath != "" { + sb.WriteString("同时已落盘为 artifact,可在需要全文时读取:\n") + sb.WriteString("artifact_path: ") + sb.WriteString(artifactPath) + sb.WriteByte('\n') + } else { + sb.WriteString("artifact 写入失败时仍可从数据库原始消息恢复。\n") + } + sb.WriteString(fmt.Sprintf("original_runes: %d\n", utf8RuneLen(userMessage))) + sb.WriteString(fmt.Sprintf("preview_head_runes: %d\n", utf8RuneLen(head))) + sb.WriteString(fmt.Sprintf("preview_tail_runes: %d\n\n", utf8RuneLen(tail))) + sb.WriteString("请优先基于以下预览理解用户目标;如必须查看全文,请读取 artifact 或数据库原始消息。\n\n") + sb.WriteString("\n") + sb.WriteString(head) + sb.WriteString("\n\n") + if tail != "" { + sb.WriteString("\n\n") + sb.WriteString(tail) + sb.WriteString("\n\n") + } + return strings.TrimSpace(sb.String()) +} + +func normalizeLatestUserPreviewBudget(maxRunes, headRunes, tailRunes int) (int, int) { + if maxRunes <= 0 { + return headRunes, tailRunes + } + if headRunes <= 0 && tailRunes <= 0 { + headRunes = maxRunes / 2 + tailRunes = maxRunes - headRunes + } + if headRunes < 0 { + headRunes = 0 + } + if tailRunes < 0 { + tailRunes = 0 + } + if headRunes+tailRunes <= maxRunes { + return headRunes, tailRunes + } + if headRunes == 0 { + return 0, maxRunes + } + if tailRunes == 0 { + return maxRunes, 0 + } + head := maxRunes / 2 + tail := maxRunes - head + return head, tail +} + +func splitHeadTailRunes(s string, headRunes, tailRunes int) (string, string) { + runes := []rune(s) + n := len(runes) + if headRunes > n { + headRunes = n + } + head := string(runes[:headRunes]) + if tailRunes <= 0 || headRunes >= n { + return head, "" + } + if tailRunes > n-headRunes { + tailRunes = n - headRunes + } + tail := string(runes[n-tailRunes:]) + return head, tail +} + +func persistLatestUserMessageArtifact(content string, appCfg *config.Config, conversationID string) (string, error) { + conversationID = strings.TrimSpace(conversationID) + if conversationID == "" { + conversationID = "unknown" + } + baseRoot := filepath.Join(os.TempDir(), "cyberstrike-user-inputs") + if appCfg != nil { + if dbPath := strings.TrimSpace(appCfg.Database.Path); dbPath != "" { + baseRoot = filepath.Join(filepath.Dir(dbPath), "conversation_artifacts") + } + } + if abs, err := filepath.Abs(baseRoot); err == nil { + baseRoot = abs + } + dir := filepath.Join(baseRoot, sanitizeEinoPathSegment(conversationID), "user_inputs") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("mkdir user input artifact dir: %w", err) + } + name := "latest_user_" + time.Now().UTC().Format("20060102T150405.000000000Z") + ".txt" + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + return "", fmt.Errorf("write user input artifact: %w", err) + } + return path, nil +} diff --git a/internal/multiagent/latest_user_message_test.go b/internal/multiagent/latest_user_message_test.go new file mode 100644 index 00000000..3a52287b --- /dev/null +++ b/internal/multiagent/latest_user_message_test.go @@ -0,0 +1,67 @@ +package multiagent + +import ( + "os" + "strings" + "testing" + + "cyberstrike-ai/internal/config" +) + +func TestPrepareLatestUserMessageForModel_CapsAndPersistsOversizedInput(t *testing.T) { + dir := t.TempDir() + appCfg := &config.Config{ + Database: config.DatabaseConfig{Path: dir + "/test.db"}, + } + mw := &config.MultiAgentEinoMiddlewareConfig{ + LatestUserMessageMaxRunes: 10, + LatestUserMessageHeadRunes: 4, + LatestUserMessageTailRunes: 4, + } + input := "abcdefghijklmnopqrst" + + out := prepareLatestUserMessageForModel(input, appCfg, mw, "conv-1", nil) + if out == input { + t.Fatal("expected oversized input to be replaced with preview") + } + if !strings.Contains(out, "artifact_path:") { + t.Fatalf("expected artifact path in preview: %q", out) + } + if !strings.Contains(out, "abcd") || !strings.Contains(out, "qrst") { + t.Fatalf("expected head and tail preview: %q", out) + } + if strings.Contains(out, "efghijklmnop") { + t.Fatalf("middle content should not remain in model preview: %q", out) + } + + path := extractArtifactPathForTest(out) + if path == "" { + t.Fatalf("could not extract artifact path: %q", out) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read artifact: %v", err) + } + if string(body) != input { + t.Fatalf("artifact body mismatch: %q", body) + } +} + +func TestPrepareLatestUserMessageForModel_ShortInputUnchanged(t *testing.T) { + mw := &config.MultiAgentEinoMiddlewareConfig{LatestUserMessageMaxRunes: 100} + input := "short" + out := prepareLatestUserMessageForModel(input, nil, mw, "conv-1", nil) + if out != input { + t.Fatalf("short input should be unchanged: %q", out) + } +} + +func extractArtifactPathForTest(s string) string { + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "artifact_path:") { + return strings.TrimSpace(strings.TrimPrefix(line, "artifact_path:")) + } + } + return "" +} diff --git a/internal/multiagent/runner.go b/internal/multiagent/runner.go index 3823a7c7..dea22bd1 100644 --- a/internal/multiagent/runner.go +++ b/internal/multiagent/runner.go @@ -75,6 +75,8 @@ func RunDeepAgent( return nil, fmt.Errorf("multiagent: 配置或 Agent 为空") } + runtimeUserMessage := prepareLatestUserMessageForModel(userMessage, appCfg, &ma.EinoMiddleware, conversationID, logger) + effectiveSubs := ma.SubAgents var markdownLoad *agents.MarkdownDirLoad var orch *agents.OrchestratorMarkdown @@ -390,7 +392,7 @@ func RunDeepAgent( } } } - if mw := newTaskContextEnrichMiddleware(userMessage, history, ma.SubAgentUserContextMaxRunesEffective(), taskBlackboardSupplement); mw != nil { + if mw := newTaskContextEnrichMiddleware(runtimeUserMessage, history, ma.SubAgentUserContextMaxRunesEffective(), taskBlackboardSupplement); mw != nil { deepHandlers = append(deepHandlers, mw) } if len(mainOrchestratorPre) > 0 { @@ -557,7 +559,7 @@ func RunDeepAgent( } baseMsgs := historyToMessages(history, appCfg, &ma.EinoMiddleware) - baseMsgs = appendUserMessageIfNeeded(baseMsgs, userMessage) + baseMsgs = appendUserMessageIfNeeded(baseMsgs, runtimeUserMessage) streamsMainAssistant := func(agent string) bool { if orchMode == "plan_execute" {