Add files via upload

This commit is contained in:
公明
2026-07-09 11:43:27 +08:00
committed by GitHub
parent 16a854d0f5
commit 37e553ba8a
8 changed files with 644 additions and 5 deletions
+2 -1
View File
@@ -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
+65 -2
View File
@@ -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)
}
}
+137
View File
@@ -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 ""
}
+4 -2
View File
@@ -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" {