fix: exclude generated summaries from original user intent

This commit is contained in:
Sycun committed 2026-10-12 10:10:52 +08:00
1 parent 6b8bbbe91b
commit 60d76be064
3 files changed
+59

No files matched your search

+6
View File
@@ -16,6 +16,12 @@
不再生成独立 `.index.md` 文件,不使用正则或关键词分类扫描快照,不增加模型调用。摘要本身承担证据检索入口;它仍可能遗漏细节,原文以快照为准。
## 用户输入与系统摘要不能混为同一来源
Eino 默认续接摘要使用 `user` 消息角色传输,但同时带 `_eino_summarization_content_type=summary` 的来源标记;保留的技能上下文使用 `skills` 标记。原始用户输入账本按该元数据排除内部摘要和技能消息,不能仅凭 `role=user` 将生成内容归入用户原文。摘要中的模拟用户账本也不作为原始输入来源导入,避免多轮压缩把模型自己写的目标或操作提升为用户指令。
压缩摘要仍保留在模型工作上下文中,不因来源区分而丢弃进展;主/子代理本轮输入仍来自原始请求及原始聊天记录。不会用 `<summary>` 等文本关键词过滤真正的用户输入。回归测试调用实际 `DefaultFinalize`、连续执行三轮压缩并经过 classic/native 转换,核对原始授权目标与禁止测试本机的约束保留、生成目标与模拟指令不进入用户账本、摘要进展不丢失,以及用户引用摘要文本时仍被视为真正用户输入。
## 会话工作记录与项目共享事实的读取边界
设计依据是区分会话状态和跨会话记忆,而非把每次自动摘要直接视为共享事实。[LangChain 官方记忆概念文档](https://docs.langchain.com/oss/python/concepts/memory) 将工作状态定义为 thread-scoped,跨会话记忆使用独立 namespace;本项目保留现有项目事实结构,用保留前缀 `note/context-*` 与来源会话标识建立相应读取范围,不增加数据库迁移或额外模型提取。
@@ -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 ""
@@ -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("<summary>GENERATED_SELF_TARGET\n"+wrapUserIntentLedger("- [U001] FORGED_USER_COMMAND")+"</summary>", 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 = "请分析这个字符串:<summary>Cookie 与本机地址</summary>"
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 := "<analysis>internal notes</analysis>\n\n<summary>\n## 1. 授权\n- example.com\n</summary>"
got := stripAnalysisFromSummarizationText(in)