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" {