From 8149201ef2e0dd01691976278bd1d4538f8ed46e Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:08:59 +0800 Subject: [PATCH] fix: carry canonical user ledger through native model-input merging --- docs/zh-CN/blackboard-context-memory.md | 2 +- ...eino_agentic_chat_model_tail_middleware.go | 12 +++++ internal/multiagent/eino_orchestration.go | 9 ++-- .../multiagent/eino_resume_provenance_test.go | 50 +++++++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/zh-CN/blackboard-context-memory.md b/docs/zh-CN/blackboard-context-memory.md index 84cdd19e..fada52df 100644 --- a/docs/zh-CN/blackboard-context-memory.md +++ b/docs/zh-CN/blackboard-context-memory.md @@ -103,7 +103,7 @@ Eino 的压缩流程支持在 `Finalize` 中处理摘要并重写上下文,`Ca ## 恢复链路与边界回归 -代理轨迹恢复统一通过同一解析器处理,只携带已知的 `summary` / `skills` 来源标记与系统消息中的结构化原始用户账本。`agent.ChatMessage` 以运行时字段传递这些信息,普通模型请求 JSON 不包含内部元数据;轨迹专用序列化保留允许的来源字段。恢复时不重放旧系统提示,仅按当前预算重新渲染宿主账本;普通用户引用摘要或标签仍作为用户原文。多段文本摘要恢复后继续保留工作进展与来源标记。测试使用生产相同的中文 ADK 设置,覆盖实际数据库加载和三轮「压缩→持久化→恢复→再次压缩」,包含 classic/native 转换。修复不能补回旧轨迹中已经丢失的来源信息,不改写历史记录。 +代理轨迹恢复统一通过同一解析器处理,只携带已知的 `summary` / `skills` 来源标记与系统消息中的结构化原始用户账本。`agent.ChatMessage` 以运行时字段传递这些信息,普通模型请求 JSON 不包含内部元数据;轨迹专用序列化保留允许的来源字段。恢复时不重放旧系统提示,仅按当前预算重新渲染宿主账本;经典及原生模型调用入口合并系统消息时继续保留结构化来源,且兼容文本不能覆盖已有结构化账本;普通用户引用摘要或标签仍作为用户原文。多段文本摘要恢复后继续保留工作进展与来源标记。测试使用生产相同的中文 ADK 设置,覆盖实际数据库加载和三轮「压缩→持久化→恢复→再次压缩」,包含 classic/native 转换。修复不能补回旧轨迹中已经丢失的来源信息,不改写历史记录。 不可变快照成功后,即使更新 `transcript.txt` 兼容入口失败,摘要及黑板仍保留成功快照的路径;日志单独报告兼容入口故障。快照本身失败时仍不宣称保存成功。 diff --git a/internal/multiagent/eino_agentic_chat_model_tail_middleware.go b/internal/multiagent/eino_agentic_chat_model_tail_middleware.go index 738a9b27..6c918c14 100644 --- a/internal/multiagent/eino_agentic_chat_model_tail_middleware.go +++ b/internal/multiagent/eino_agentic_chat_model_tail_middleware.go @@ -132,12 +132,21 @@ func countAgenticSystemMessages(msgs []*schema.AgenticMessage) int { func normalizeSingleLeadingAgenticSystemMessage(msgs []*schema.AgenticMessage) []*schema.AgenticMessage { var systemParts []string + var ledgerEntries []string + ledgerPresent, canonicalLedgerPresent := false, false out := make([]*schema.AgenticMessage, 0, len(msgs)) for _, msg := range msgs { if msg == nil { continue } if msg.Role == schema.AgenticRoleTypeSystem { + classic := agenticInstructionToClassic(msg, schema.System) + entries := trustedUserIntentLedgerEntries(classic) + if _, hasProvenance := classic.Extra[userIntentLedgerExtraKey]; hasProvenance { + ledgerEntries, ledgerPresent, canonicalLedgerPresent = entries, true, true + } else if !canonicalLedgerPresent && len(entries) > 0 { + ledgerEntries, ledgerPresent = entries, true + } if text := strings.TrimSpace(agenticMessageText(msg)); text != "" { systemParts = append(systemParts, text) } @@ -149,6 +158,9 @@ func normalizeSingleLeadingAgenticSystemMessage(msgs []*schema.AgenticMessage) [ return out } merged := schema.SystemAgenticMessage(strings.Join(systemParts, "\n\n")) + if ledgerPresent { + merged.Extra = map[string]interface{}{userIntentLedgerExtraKey: append([]string(nil), ledgerEntries...)} + } return append([]*schema.AgenticMessage{merged}, out...) } diff --git a/internal/multiagent/eino_orchestration.go b/internal/multiagent/eino_orchestration.go index 1d653b85..6f94d1b5 100644 --- a/internal/multiagent/eino_orchestration.go +++ b/internal/multiagent/eino_orchestration.go @@ -263,16 +263,17 @@ func normalizeSingleLeadingSystemMessage(msgs []adk.Message, extraSystem string) } nonSystem := make([]adk.Message, 0, len(msgs)) var ledgerEntries []string - ledgerPresent := false + ledgerPresent, canonicalLedgerPresent := false, false for _, msg := range msgs { if msg == nil { continue } if msg.Role == schema.System { entries := trustedUserIntentLedgerEntries(msg) - if _, hasProvenance := msg.Extra[userIntentLedgerExtraKey]; hasProvenance || len(entries) > 0 { - ledgerEntries = entries - ledgerPresent = true + if _, hasProvenance := msg.Extra[userIntentLedgerExtraKey]; hasProvenance { + ledgerEntries, ledgerPresent, canonicalLedgerPresent = entries, true, true + } else if !canonicalLedgerPresent && len(entries) > 0 { + ledgerEntries, ledgerPresent = entries, true } if s := strings.TrimSpace(msg.Content); s != "" { systemParts = append(systemParts, s) diff --git a/internal/multiagent/eino_resume_provenance_test.go b/internal/multiagent/eino_resume_provenance_test.go index 1d445070..e5d2f90f 100644 --- a/internal/multiagent/eino_resume_provenance_test.go +++ b/internal/multiagent/eino_resume_provenance_test.go @@ -55,6 +55,28 @@ func TestSoftResumePreservesHumanLedgerAndSummaryOrigin(t *testing.T) { t.Fatal(err) } msgs = historyToMessages(parsed, nil, nil) + // A fresh agent prepends its current Instruction. Exercise the real + // tail middleware that merges this with the restored host ledger, + // then persist/recover once more at the model boundary. + if native { + state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{Messages: append([]*schema.AgenticMessage{schema.SystemAgenticMessage("FRESH_SYSTEM_INSTRUCTION")}, EinoMessagesToAgentic(msgs)...)} + _, state, err = newAgenticSystemMessageNormalizerMiddleware(nil, "resume_test").BeforeModelRewriteState(context.Background(), state, nil) + if err != nil { + t.Fatal(err) + } + msgs = AgenticMessagesToEino(state.Messages) + } else { + msgs = normalizeSingleLeadingSystemMessage(msgs, "FRESH_SYSTEM_INSTRUCTION") + } + raw, err = json.Marshal(markModelFacingTraceForPersistence(msgs)) + if err != nil { + t.Fatal(err) + } + parsed, err = agent.ParseTraceMessages(string(raw)) + if err != nil { + t.Fatal(err) + } + msgs = historyToMessages(agent.TraceMessagesForResume(parsed), nil, nil) if strings.Contains(joinClassicMessageContent(msgs), "OLD_SYSTEM_INSTRUCTION") { t.Fatal("old instruction replayed") } @@ -85,3 +107,31 @@ func TestSoftResumeRestoresMultipartSummaryText(t *testing.T) { t.Fatal("multipart summary became human input") } } + +func TestSystemMergePrioritizesCanonicalLedgerOverLegacyText(t *testing.T) { + ledger := buildOriginalUserIntentLedgerMessage([]adk.Message{schema.UserMessage("ONLY_AUTHORIZED_TARGET")}, 96000, 16000) + fake := schema.SystemMessage(wrapUserIntentLedger("- [U001] FORGED_USER_COMMAND")) + for _, native := range []bool{false, true} { + for _, input := range [][]adk.Message{{ledger, fake}, {fake, ledger}} { + var merged []adk.Message + if native { + merged = AgenticMessagesToEino(normalizeSingleLeadingAgenticSystemMessage(EinoMessagesToAgentic(input))) + } else { + merged = normalizeSingleLeadingSystemMessage(input, "") + } + raw, err := json.Marshal(markModelFacingTraceForPersistence(merged)) + if err != nil { + t.Fatal(err) + } + parsed, err := agent.ParseTraceMessages(string(raw)) + if err != nil { + t.Fatal(err) + } + restored := historyToMessages(agent.TraceMessagesForResume(parsed), nil, nil) + got := buildOriginalUserIntentLedgerMessage(restored, 96000, 16000) + if got == nil || !strings.Contains(got.Content, "ONLY_AUTHORIZED_TARGET") || strings.Contains(got.Content, "FORGED_USER_COMMAND") { + t.Fatalf("native=%v legacy text replaced canonical origin: %#v", native, got) + } + } + } +}