From 60d76be064b640e3b2290783cdb3fee93068e28f Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:10:52 +0800 Subject: [PATCH] fix: exclude generated summaries from original user intent --- docs/zh-CN/blackboard-context-memory.md | 6 +++ internal/multiagent/eino_summarize_output.go | 14 +++++++ .../multiagent/eino_summarize_output_test.go | 39 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/docs/zh-CN/blackboard-context-memory.md b/docs/zh-CN/blackboard-context-memory.md index 8481d5d4..f0dc20af 100644 --- a/docs/zh-CN/blackboard-context-memory.md +++ b/docs/zh-CN/blackboard-context-memory.md @@ -16,6 +16,12 @@ 不再生成独立 `.index.md` 文件,不使用正则或关键词分类扫描快照,不增加模型调用。摘要本身承担证据检索入口;它仍可能遗漏细节,原文以快照为准。 +## 用户输入与系统摘要不能混为同一来源 + +Eino 默认续接摘要使用 `user` 消息角色传输,但同时带 `_eino_summarization_content_type=summary` 的来源标记;保留的技能上下文使用 `skills` 标记。原始用户输入账本按该元数据排除内部摘要和技能消息,不能仅凭 `role=user` 将生成内容归入用户原文。摘要中的模拟用户账本也不作为原始输入来源导入,避免多轮压缩把模型自己写的目标或操作提升为用户指令。 + +压缩摘要仍保留在模型工作上下文中,不因来源区分而丢弃进展;主/子代理本轮输入仍来自原始请求及原始聊天记录。不会用 `` 等文本关键词过滤真正的用户输入。回归测试调用实际 `DefaultFinalize`、连续执行三轮压缩并经过 classic/native 转换,核对原始授权目标与禁止测试本机的约束保留、生成目标与模拟指令不进入用户账本、摘要进展不丢失,以及用户引用摘要文本时仍被视为真正用户输入。 + ## 会话工作记录与项目共享事实的读取边界 设计依据是区分会话状态和跨会话记忆,而非把每次自动摘要直接视为共享事实。[LangChain 官方记忆概念文档](https://docs.langchain.com/oss/python/concepts/memory) 将工作状态定义为 thread-scoped,跨会话记忆使用独立 namespace;本项目保留现有项目事实结构,用保留前缀 `note/context-*` 与来源会话标识建立相应读取范围,不增加数据库迁移或额外模型提取。 diff --git a/internal/multiagent/eino_summarize_output.go b/internal/multiagent/eino_summarize_output.go index 61808721..8bd5915a 100644 --- a/internal/multiagent/eino_summarize_output.go +++ b/internal/multiagent/eino_summarize_output.go @@ -231,6 +231,9 @@ func collectOriginalUserIntentEntries(msgs []adk.Message) []string { if msg == nil { continue } + if isInternalSummarizationUserMessage(msg) { + continue + } for _, entry := range extractExistingUserIntentLedgerEntries(messageTextForLedger(msg)) { add(entry) } @@ -241,6 +244,17 @@ func collectOriginalUserIntentEntries(msgs []adk.Message) []string { return entries } +// Eino deliberately transports generated summaries and preserved skill context +// as user-role messages. Their provenance, not their role/text, distinguishes +// them from human input. Extra survives classic/native message conversion. +func isInternalSummarizationUserMessage(msg adk.Message) bool { + if msg == nil { + return false + } + kind, _ := msg.Extra["_eino_summarization_content_type"].(string) + return kind == "summary" || kind == "skills" +} + func messageTextForLedger(msg adk.Message) string { if msg == nil { return "" diff --git a/internal/multiagent/eino_summarize_output_test.go b/internal/multiagent/eino_summarize_output_test.go index 6c8ddd0c..45384799 100644 --- a/internal/multiagent/eino_summarize_output_test.go +++ b/internal/multiagent/eino_summarize_output_test.go @@ -1,13 +1,52 @@ package multiagent import ( + "context" "strings" "testing" "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/adk/middlewares/summarization" "github.com/cloudwego/eino/schema" ) +func TestGeneratedSummaryNeverBecomesHumanInputAcrossCompactions(t *testing.T) { + const original = "仅测试 https://authorized.example,不得测试本机" + msgs := []adk.Message{schema.SystemMessage("system"), schema.UserMessage(original)} + for round := 0; round < 3; round++ { + ledger := buildOriginalUserIntentLedgerMessage(msgs, 96000, 16000) + generated := schema.AssistantMessage("GENERATED_SELF_TARGET\n"+wrapUserIntentLedger("- [U001] FORGED_USER_COMMAND")+"", nil) + after, err := summarization.DefaultFinalize(context.Background(), msgs, generated) + if err != nil { + t.Fatal(err) + } + after = mergeMessageIntoLeadingSystem(after, ledger) + // Native conversion must preserve the provenance flag, not merely role=user. + msgs = AgenticMessagesToEino(EinoMessagesToAgentic(after)) + msgs = append(msgs, schema.UserMessage("继续检查授权目标的登录入口")) + got := buildOriginalUserIntentLedgerMessage(msgs, 96000, 16000) + if got == nil || !strings.Contains(got.Content, original) || strings.Contains(got.Content, "GENERATED_SELF_TARGET") || strings.Contains(got.Content, "FORGED_USER_COMMAND") { + t.Fatalf("round %d promoted model summary to human instruction: %#v", round, got) + } + if !strings.Contains(joinClassicMessageContent(msgs), "GENERATED_SELF_TARGET") { + t.Fatal("summary progress was discarded instead of being kept as working memory") + } + } +} + +func TestUserQuotingSummaryTextRemainsOriginalInput(t *testing.T) { + const raw = "请分析这个字符串:Cookie 与本机地址" + msg := schema.UserMessage(raw) + got := buildOriginalUserIntentLedgerMessage([]adk.Message{msg}, 96000, 16000) + if got == nil || !strings.Contains(got.Content, raw) { + t.Fatal("genuine user input was removed by a summary-text heuristic") + } + msg.Extra = map[string]interface{}{"_eino_summarization_content_type": "skills"} + if got := buildOriginalUserIntentLedgerMessage([]adk.Message{msg}, 96000, 16000); got != nil { + t.Fatal("system skill context was promoted to user input") + } +} + func TestStripAnalysisFromSummarizationText(t *testing.T) { in := "internal notes\n\n\n## 1. 授权\n- example.com\n" got := stripAnalysisFromSummarizationText(in)