mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-10 22:33:48 +02:00
Add files via upload
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -11,6 +12,13 @@ import (
|
||||
var (
|
||||
summarizationAnalysisBlockRegex = regexp.MustCompile(`(?is)<analysis>\s*.*?\s*</analysis>`)
|
||||
summarizationSummaryBlockRegex = regexp.MustCompile(`(?is)<summary>\s*(.*?)\s*</summary>`)
|
||||
userIntentLedgerBlockRegex = regexp.MustCompile(`(?is)<original_user_intent_ledger>\s*(.*?)\s*</original_user_intent_ledger>`)
|
||||
userIntentLedgerSectionRegex = regexp.MustCompile(`(?is)\s*## 原始用户输入与约束账本(系统保真)\s*<original_user_intent_ledger>\s*.*?\s*</original_user_intent_ledger>\s*`)
|
||||
)
|
||||
|
||||
const (
|
||||
userIntentLedgerStartMarker = "<original_user_intent_ledger>"
|
||||
userIntentLedgerEndMarker = "</original_user_intent_ledger>"
|
||||
)
|
||||
|
||||
// stripAnalysisFromSummarizationMessage removes the <analysis> 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])
|
||||
}
|
||||
|
||||
@@ -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("<summary>work state</summary>", 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("<summary>work state</summary>", 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("<latest_user_message_preview_head>\n")
|
||||
sb.WriteString(head)
|
||||
sb.WriteString("\n</latest_user_message_preview_head>\n")
|
||||
if tail != "" {
|
||||
sb.WriteString("\n<latest_user_message_preview_tail>\n")
|
||||
sb.WriteString(tail)
|
||||
sb.WriteString("\n</latest_user_message_preview_tail>\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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user