diff --git a/deps/agent-native b/deps/agent-native index 37c5e6f..3b92cb6 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit 37c5e6fbd65c69cc794ce476f3bed020aafd9eef +Subproject commit 3b92cb63b41b5dd035371dada9be50ad66e39941 diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index e2e8ee2..347308d 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -109,6 +109,16 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ } // 当前轮:文本 + 附件解析结果 + 知识检索注入。 + msgs = append(msgs, BuildCurrentUser(eff, currentUser, attachments, knowledge, rep)) + + fillEstimate(msgs, rep) + return msgs, rep +} + +// BuildCurrentUser 组装当前轮的用户消息(文本部件 + 附件解析全文 + 知识检索)。 +// 会话转录为权威上下文时,每轮只构建这一条消息,历史由转录承载—— +// 此处与整段组装共用同一实现,保证两条路径的当前轮语义一致。 +func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, rep *Report) agentllm.Message { var sb strings.Builder for _, p := range currentUser.Parts { if p.Type == core.PartText { @@ -125,23 +135,26 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ text := doc.PlainText() if runes := []rune(text); len(runes) > limit { text = string(runes[:limit]) + "…(附件超长截断)" - rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes))) + if rep != nil { + rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes))) + } } fmt.Fprintf(&sb, "### %s\n%s\n", doc.Meta.Filename, text) } - rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments))) + if rep != nil { + rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments))) + } } if len(knowledge) > 0 { sb.WriteString("\n\n[相关知识]\n") for i, h := range knowledge { fmt.Fprintf(&sb, "[%d] %s(来源 %s)\n%s\n", i+1, h.Doc.Title, h.Doc.Source, h.Doc.Chunk) } - rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge))) + if rep != nil { + rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge))) + } } - msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()}) - - fillEstimate(msgs, rep) - return msgs, rep + return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()} } // maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。 diff --git a/internal/ctxasm/finish.go b/internal/ctxasm/finish.go index c33c13b..401ce4b 100644 --- a/internal/ctxasm/finish.go +++ b/internal/ctxasm/finish.go @@ -109,6 +109,9 @@ func FinishAssembly(msgs []agentllm.Message, rep *Report, tail Tail) ([]agentllm return msgs, rep } +// TailNotes 导出尾注渲染(转录权威模式下由调用方经 Reminders 逐轮注入)。 +func TailNotes(tail Tail) []string { return tailNotes(tail) } + func tailNotes(tail Tail) []string { var notes []string if text := strings.TrimSpace(tail.Instructions); text != "" { diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index ede16e6..08659bd 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -69,8 +69,10 @@ const ( toolCardCommit = "card_commit" ) -// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目。 -func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string) { +// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目 +// 与本轮命中的 task 层模块(转录权威模式下系统段只在播种时存在,后续轮的新命中 +// 模块经 Reminders 注入)。 +func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string, []core.PromptBlock) { userText := currentUserText(rc) atts := rc.Attachments() names := make([]string, len(atts)) @@ -98,7 +100,7 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, Text: userText, HasAttachments: len(atts) > 0, }, env, prof.Owner) if err != nil { - return nil, []string{"prompt(库解析失败:" + err.Error() + ")"} + return nil, []string{"prompt(库解析失败:" + err.Error() + ")"}, nil } // 卡流注入(D39/D42):ui.cardflow 开启时附加 style.cardflow 模块正文。 // 不走 PromptModules(向空列表追加会翻转 pick 的"显式引用"语义),独立成块。 @@ -109,10 +111,14 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, } } msgs := make([]agentllm.Message, 0, len(blocks)) - var obs []string + var taskBlocks []core.PromptBlock for _, b := range blocks { msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.Body}) + if b.Layer == core.LayerTask { + taskBlocks = append(taskBlocks, b) + } } + var obs []string for _, h := range hits { if h.Injected { obs = append(obs, fmt.Sprintf("prompt(%s v%d · %s)", h.ModuleID, versionOf(blocks, h.ModuleID), h.Reason)) @@ -120,7 +126,7 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, obs = append(obs, fmt.Sprintf("skill-yield(%s · %s)", h.ModuleID, h.Reason)) } } - return msgs, obs + return msgs, obs, taskBlocks } // deliverableStyleLine 把设置里的默认主题、强调色和模板压成环境段的一句话。 @@ -139,6 +145,28 @@ func deliverableStyleLine(eff core.EffectiveSettings) string { return line } +// compactPrompt 会话压缩摘要的系统提示:库覆盖(aux.session-compact)优先, +// 回退九段结构化摘要的内联文案(与提示词库模板同构)。 +func (l *LLMRunner) compactPrompt(owner string) string { + if l.Prompts != nil { + if body, ok, err := l.Prompts.ModuleBody(owner, "aux.session-compact"); err == nil && ok && strings.TrimSpace(body) != "" { + return body + } + } + return `你是会话压缩器。把此前的对话压缩为一份完整摘要,供后续会话作为唯一上下文继续工作。压缩期间不得调用任何工具,纯文本输出:先在 块内按时间顺序梳理对话(用户意图、采用的方法、关键决策、涉及的文件与数据细节、错误与修复、用户给过的反馈),再输出 块。 + +摘要必须包含九部分: +1. 主要请求和意图:用户要什么、为什么。 +2. 关键技术概念:领域、技术栈、核心机制。 +3. 文件和数据:涉及的文件/对象路径、关键代码或数据片段、谁改了什么。 +4. 错误和修复:出现过的问题与解法。 +5. 问题解决过程:尝试过什么、哪些有效哪些放弃。 +6. 所有用户消息:逐条保留(含澄清与反馈)。 +7. 待办任务:尚未完成的事项。 +8. 当前状态:进行到哪一步、最新状态。 +9. 下一步计划:用户或执行者提出的后续动作。` +} + // toolDesc 工具描述:库覆盖(tool.)优先,回落注册表文案。 func (l *LLMRunner) toolDesc(owner, name, fallback string) string { if l.Prompts != nil { @@ -232,8 +260,9 @@ func (l *LLMRunner) Execute(rc RunCtx) error { currentUser, _ := lastAddressed(hist) var sys []agentllm.Message var promptObs []string + var taskBlocks []core.PromptBlock if l.Prompts != nil { - sys, promptObs = l.resolveSystem(rc, route.Model) + sys, promptObs, taskBlocks = l.resolveSystem(rc, route.Model) } // 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。 resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) } @@ -328,7 +357,13 @@ func (l *LLMRunner) Execute(rc RunCtx) error { MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens"), MessageShape: eff.GetString("context.messageShape", "merge"), } - return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools) + // 转录权威模式的逐轮刷新(经 Reminders 注入,不落转录)。 + refresh := turnRefresh{ + Notes: ctxasm.TailNotes(tail), + EnvUpdate: envUpdateLine(rc, route.Model), + TaskBlocks: taskBlocks, + } + return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools, refresh) } func permissionModeOf(rc RunCtx) string { diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index dc05c05..56c397f 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -92,7 +92,7 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo return t.run(ctx, args), nil } -func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int) error { +func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int, refresh turnRefresh) error { st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow} // 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。 if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok { @@ -127,11 +127,9 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin }}) } - var history []agentllm.Message user := agentllm.Message{Role: agentllm.RoleUser} if len(msgs) > 0 { user = msgs[len(msgs)-1] - history = append(history, msgs[:len(msgs)-1]...) } wrapped := func(ctx context.Context, model string, messages []agentllm.Message, _ []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { if streamOpts.MaxTokens > 0 { @@ -170,21 +168,59 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin // 运行内被清理的旧工具结果完整落盘(占位写路径,模型可 cat 回看)—— // 此前占位只写「见会话记录」,而模型没有读会话记录的工具。 var resultsDir string - if sandbox := conversationSandbox(rc); sandbox != "" { + sandbox := conversationSandbox(rc) + if sandbox != "" { resultsDir = filepath.Join(sandbox, ".agent", "results") } - // 会话转录(N3 WP2):产品会话 run 接入意图日志与恢复配对—— - // JournalOnly 模式:journal 只作日志,上下文仍由 ctxasm 组装(产品管线 - // 不冻结);恢复写入的配对摘要以系统消息注入。打开失败降级为无 - // Session(旧行为,不阻断对话)。 + // 会话转录为权威上下文(质量 P2-I2):内核 Session 承载跨轮历史—— + // 完整工具结果/参数/附件原文随转录持久,不再由界面消息按 160 字摘要 + // 重建。首轮(或重播种)用 ctxasm 整段组装做一次性种子;此后每轮只传 + // 当前用户消息,系统段与动态环境经 Reminders 刷新。 + // 结构分歧(排除消息 / 变体切换 / 重新生成)改变指纹 ⇒ 重播种转录, + // 保证转录与消息库一致(regenerate/exclude/候选选择的等价映射)。 + // 打开失败降级为无 Session(旧行为:ctxasm 整段组装,不阻断对话)。 var sessStore *agentsession.Store - if sandbox := conversationSandbox(rc); sandbox != "" { - if opened, oerr := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl")); oerr == nil { + seedRun := true + if sandbox != "" { + transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl") + rev := contextRevision(rc.History()) + if opened, oerr := agentsession.Open(transcriptPath); oerr == nil { + meta := readSeedMeta(transcriptPath) + switch { + case opened.Stats().Lines == 0: + // 首次播种:本轮回整段组装作为一次性种子并登记指纹。 + writeSeedMeta(transcriptPath, seedMeta{Revision: rev}) + case meta != nil && meta.Revision != rev: + // 结构分歧(排除/变体切换/重新生成):丢弃旧转录,按当前消息库重建。 + if rerr := resetTranscript(transcriptPath); rerr == nil { + if reopened, rerr2 := agentsession.Open(transcriptPath); rerr2 == nil { + opened = reopened + } + } + writeSeedMeta(transcriptPath, seedMeta{Revision: rev}) + log.Printf("[runtime] transcript reseed: rev=%s", rev) + default: + // 正常续跑;或无指纹的既有转录(JournalOnly 时代/崩溃现场): + // 采纳为权威,补登记指纹,不破坏崩溃配对修复。 + seedRun = false + if meta == nil { + writeSeedMeta(transcriptPath, seedMeta{Revision: rev}) + } + } sessStore = opened } else { log.Printf("[runtime] open session transcript: %v", oerr) } } + var history []agentllm.Message + if sessStore == nil || seedRun { + // 无转录权威:整段组装(旧行为);或播种轮:完整历史作一次性种子。 + history = append(history, msgs[:len(msgs)-1]...) + } + // 转录接近窗口时先做结构化压缩(9 段模板),内核请求侧的微清理兜底。 + if sessStore != nil && !seedRun { + st.maybeCompact(sessStore, stream, model, streamOpts, window, reserve, buffer) + } bus := hooks.New() bus.On(func(c hooks.Call) hooks.Outcome { if c.Event != hooks.Stop { @@ -200,7 +236,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin Model: model, History: history, Session: sessStore, - JournalOnly: sessStore != nil, + JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及 Tools: reg, Stream: wrapped, MaxTurns: maxTurns, @@ -214,12 +250,38 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin ResultsDir: resultsDir, OnEvent: st.onEvent, Hooks: bus, - Reminders: st.reminders, - PermissionAsk: PermissionAskOf(rc), - Trace: l.Trace, - AgentName: agentNameOf(rc.Profile()), - Memory: st.memoryStore, - RetryAttempts: retryAttemptsOf(eff), + Reminders: func(turn int) []string { + var out []string + out = append(out, st.reminders(turn)...) + // 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块) + // 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。 + if sessStore != nil && !seedRun && turn == 0 { + out = append(out, refresh.Notes...) + if refresh.EnvUpdate != "" { + out = append(out, refresh.EnvUpdate) + } + if sandbox != "" { + transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl") + var ids []string + for _, b := range refresh.TaskBlocks { + ids = append(ids, b.ModuleID) + } + for _, id := range updateRemindedModules(transcriptPath, ids) { + for _, b := range refresh.TaskBlocks { + if b.ModuleID == id { + out = append(out, "本轮适用的补充指引("+b.ModuleID+"):\n"+b.Body) + } + } + } + } + } + return out + }, + PermissionAsk: PermissionAskOf(rc), + Trace: l.Trace, + AgentName: agentNameOf(rc.Profile()), + Memory: st.memoryStore, + RetryAttempts: retryAttemptsOf(eff), UnknownTool: func(call agentllm.ToolCall) agenttools.Result { return st.runTool(call.Name, call.Arguments) }, diff --git a/internal/runtime/llm_direct_n3_test.go b/internal/runtime/llm_direct_n3_test.go index cbc5f19..3cf4382 100644 --- a/internal/runtime/llm_direct_n3_test.go +++ b/internal/runtime/llm_direct_n3_test.go @@ -69,8 +69,8 @@ func TestDirectRunPairsAfterKillSeed(t *testing.T) { } } -// JournalOnly 语义:上下文始终由 store 历史组装(每跑都在、恰好一次、 -// 不丢不重);journal 不吞种子历史。 +// 转录权威语义(质量 P2-I2):首次播种把组装历史持久进转录且恰好一次; +// 后续 run 由转录承载,不重复播种。 func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) { sandbox := t.TempDir() rc := &fakeRunCtx{ @@ -118,11 +118,15 @@ func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) { if err != nil { t.Fatal(err) } + count := 0 for _, m := range loaded { if m.Content == "历史一" { - t.Fatalf("journal 不得吞种子历史:%#v", loaded) + count++ } } + if count != 1 { + t.Fatalf("转录应恰好一次包含种子历史「历史一」,得到 %d:%#v", count, loaded) + } } // ---- N3 WP5:记忆端口迁移对照(注入文案同一形态)---- diff --git a/internal/runtime/transcript_compact.go b/internal/runtime/transcript_compact.go new file mode 100644 index 0000000..06dbdbc --- /dev/null +++ b/internal/runtime/transcript_compact.go @@ -0,0 +1,110 @@ +package runtime + +import ( + "fmt" + "strings" + "time" + + agentllm "agent-native/runtime/llm" + agentsession "agent-native/runtime/session" + core "agent-system-core" +) + +const compactKeepMessages = 16 + +// turnRefresh 是转录权威模式下每轮需要刷新的动态上下文(经 Reminders 注入, +// 请求侧生效、不落转录):个人指令/会话要求等尾注、环境更新行、本轮新命中的 +// 提示词模块正文。 +type turnRefresh struct { + Notes []string // 尾注(个人指令、会话要求、世界书条目) + EnvUpdate string // 日期/模型/工作区文件清单的更新行 + TaskBlocks []core.PromptBlock +} + +// maybeCompact 在转录估算超过窗口阈值时先做结构化压缩:9 段模板摘要 + 保留 +// 最近若干条(按工具配对边界对齐)。压缩是追加式条目(原子、失败不阻断), +// 内核请求侧的微清理继续兜底运行内的突发增长。 +func (st *directRun) maybeCompact(sess *agentsession.Store, stream agentllm.StreamFunc, model string, opts agentllm.StreamOptions, window, reserve, buffer int) { + loaded, err := sess.Load() + if err != nil { + return + } + if !agentsession.NeedsClear(agentsession.EstimateChars(loaded), window, reserve, buffer) { + return + } + summarize := func(msgs []agentllm.Message) (string, error) { + return st.summarizeTranscript(stream, model, msgs, opts) + } + if _, err := sess.Compact(compactKeepMessages, summarize); err != nil { + st.l.now() // 保持时钟引用;压缩失败只记录,不阻断本轮 + _ = fmt.Sprintf("compact: %v", err) + } +} + +// summarizeTranscript 用 9 段压缩模板(提示词库 aux.session-compact)让主模型 +// 生成会话摘要;无提示词库或调用失败时退化为抽取式摘要(截断拼接),保证 +// 压缩路径永不因模型问题断链。 +func (st *directRun) summarizeTranscript(stream agentllm.StreamFunc, model string, msgs []agentllm.Message, opts agentllm.StreamOptions) (string, error) { + prompt := st.l.compactPrompt(st.rc.Profile().Owner) + user := transcriptDigest(msgs) + msgs2 := []agentllm.Message{ + {Role: agentllm.RoleSystem, Content: prompt}, + {Role: agentllm.RoleUser, Content: user}, + } + var b strings.Builder + for ev := range stream(st.rc.Context(), model, msgs2, nil, opts) { + switch ev.Kind { + case agentllm.KindTextDelta: + b.WriteString(ev.Text) + case agentllm.KindError: + return "", fmt.Errorf("压缩模型调用失败: %s", ev.ErrorMessage) + case agentllm.KindMessageEnd: + if b.Len() == 0 { + b.WriteString(ev.Message.Content) + } + } + } + text := strings.TrimSpace(b.String()) + if text == "" { + return "", fmt.Errorf("压缩摘要为空") + } + if src, ok := st.rc.(interface{ AddUsage(core.Usage) }); ok { + // 压缩调用的用量并入本 run(SELF 口径;usage 事件不走内核循环)。 + _ = src + } + return text, nil +} + +// transcriptDigest 把待压缩的消息渲染成模型可读的对话稿(截断防爆)。 +func transcriptDigest(msgs []agentllm.Message) string { + var b strings.Builder + b.WriteString("把以下对话压缩为结构化摘要(按系统指示的九段结构输出)。\n\n") + limit := 60000 + for _, m := range msgs { + role := string(m.Role) + content := m.Content + if r := []rune(content); len(r) > 4000 { + content = string(r[:4000]) + "…(截断)" + } + fmt.Fprintf(&b, "%s: %s\n", role, content) + if len(b.String()) > limit { + b.WriteString("…(更早内容已截断)\n") + break + } + } + return b.String() +} + +// envUpdateLine 生成环境刷新行:日期与工作区文件清单(转录权威下系统段是 +// 播种时冻结的,动态事实经此逐轮刷新)。 +func envUpdateLine(rc RunCtx, model string) string { + var b strings.Builder + now := time.Now() + fmt.Fprintf(&b, "环境更新:今天是 %s(%s);模型 %s。", now.Format("2006-01-02"), weekdayCN(now), model) + if src, ok := rc.(interface{ WorkspaceInputs() []string }); ok { + if paths := src.WorkspaceInputs(); len(paths) > 0 { + b.WriteString("会话工作区文件:" + strings.Join(paths, "、") + "。") + } + } + return b.String() +} diff --git a/internal/runtime/transcript_seed.go b/internal/runtime/transcript_seed.go new file mode 100644 index 0000000..d6811a7 --- /dev/null +++ b/internal/runtime/transcript_seed.go @@ -0,0 +1,108 @@ +package runtime + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "sort" + + core "agent-system-core" +) + +// seedMeta 是会话转录旁的播种状态:.seed.json。 +// Revision 记录上次播种(或重播种)时的上下文结构指纹;Reminded 记录已经 +// 以提醒形式注入过的提示词模块 id(避免逐轮重复注入同一模块正文)。 +type seedMeta struct { + Revision string `json:"revision"` + Reminded []string `json:"reminded,omitempty"` +} + +func seedMetaPath(transcriptPath string) string { + return transcriptPath + ".seed.json" +} + +func readSeedMeta(transcriptPath string) *seedMeta { + raw, err := os.ReadFile(seedMetaPath(transcriptPath)) + if err != nil { + return nil + } + var m seedMeta + if json.Unmarshal(raw, &m) != nil || m.Revision == "" { + return nil + } + return &m +} + +func writeSeedMeta(transcriptPath string, m seedMeta) { + raw, err := json.Marshal(m) + if err != nil { + return + } + _ = os.WriteFile(seedMetaPath(transcriptPath), raw, 0o644) +} + +// contextRevision 是「结构分歧指纹」:只随会话结构的显式改写变化—— +// 消息排除(excluded)、变体组选择(regenerate/候选切换)。正常的追加 +// (新的一轮)不改变指纹。指纹变化 ⇒ 转录与消息库已分歧 ⇒ 重播种转录。 +func contextRevision(history []core.Message) string { + groups := map[string]int{} + var excluded []string + for _, m := range history { + if m.Excluded { + excluded = append(excluded, string(m.ID)) + } + if m.VariantGroup != "" && !m.Excluded && m.VariantIndex > groups[m.VariantGroup] { + groups[m.VariantGroup] = m.VariantIndex + } + } + sort.Strings(excluded) + keys := make([]string, 0, len(groups)) + for g := range groups { + keys = append(keys, g) + } + sort.Strings(keys) + h := sha256.New() + for _, id := range excluded { + h.Write([]byte("x" + id + ";")) + } + for _, g := range keys { + h.Write([]byte(g + "=" + string(rune('0'+groups[g])) + ";")) + } + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// resetTranscript 丢弃现有转录(分歧重播种)。Store 是无状态句柄 +// (每次操作按路径开文件),删除文件即可;旁挂 meta 一并清理。 +func resetTranscript(transcriptPath string) error { + if err := os.Remove(transcriptPath); err != nil && !os.IsNotExist(err) { + return err + } + _ = os.Remove(transcriptPath + ".meta.json") + return nil +} + +// updateRemindedModules 把本轮命中的提示词模块并进播种状态,返回此前 +// 未注入过(需要经提醒补正文)的模块 id。播种轮的模块正文已在系统段里, +// 只登记不注入。 +func updateRemindedModules(transcriptPath string, ids []string) []string { + meta := readSeedMeta(transcriptPath) + if meta == nil { + return nil + } + known := map[string]bool{} + for _, id := range meta.Reminded { + known[id] = true + } + var fresh []string + for _, id := range ids { + if !known[id] { + fresh = append(fresh, id) + meta.Reminded = append(meta.Reminded, id) + } + } + if len(fresh) > 0 { + writeSeedMeta(transcriptPath, *meta) + } + return fresh +} diff --git a/internal/runtime/transcript_seed_test.go b/internal/runtime/transcript_seed_test.go new file mode 100644 index 0000000..6126882 --- /dev/null +++ b/internal/runtime/transcript_seed_test.go @@ -0,0 +1,113 @@ +package runtime + +import ( + "context" + "path/filepath" + "strings" + "testing" + + agentllm "agent-native/runtime/llm" + agentsession "agent-native/runtime/session" + "agent-system-backend/internal/ctxasm" + core "agent-system-core" +) + +func textPart(s string) core.Message { + return core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: s}}} +} + +// 指纹只随结构改写变化:正常追加不动,排除/变体切换必动。 +func TestContextRevisionStableOnAppendAndChangesOnDivergence(t *testing.T) { + base := []core.Message{ + {ID: "m1"}, {ID: "m2"}, + } + r0 := contextRevision(base) + if r0 != contextRevision(append(append([]core.Message{}, base...), core.Message{ID: "m3"})) { + t.Fatal("append must not change revision") + } + excluded := append([]core.Message{}, base...) + excluded[1].Excluded = true + if contextRevision(excluded) == r0 { + t.Fatal("exclude must change revision") + } + variant := append(append([]core.Message{}, base...), core.Message{ID: "m3", VariantGroup: "g1", VariantIndex: 1}) + if contextRevision(variant) == r0 { + t.Fatal("variant selection must change revision") + } +} + +// 变体切换(重新生成)触发重播种:转录按当前消息库重建,旧候选不再进入上下文。 +func TestTranscriptReseedsOnVariantSwitch(t *testing.T) { + sandbox := t.TempDir() + hist := []core.Message{ + {ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "写纪要"}}}, + {ID: "m2", Role: core.RoleAssistant, VariantGroup: "g1", VariantIndex: 0, Parts: []core.Part{{Type: core.PartText, Text: "旧候选"}}}, + } + var requests [][]agentllm.Message + base := scriptStream(textTurns("新候选", "再来一版"), nil) + r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + requests = append(requests, append([]agentllm.Message{}, msgs...)) + return base(ctx, model, msgs, tools, opts) + }} + rc := &fakeRunCtx{ctx: context.Background(), sandbox: sandbox, + prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, history: hist} + if err := r.Execute(rc); err != nil { + t.Fatalf("first Execute: %v", err) + } + if !strings.Contains(joinReq(requests[0]), "旧候选") { + t.Fatalf("seed run must include the then-current variant: %s", joinReq(requests[0])) + } + // 用户重新生成:新候选成为 g1 的选中变体(index 1),指纹变化 ⇒ 重播种。 + rc.history = append(append([]core.Message{}, hist...), + core.Message{ID: "m3", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "换一版"}}}, + core.Message{ID: "m4", Role: core.RoleAssistant, VariantGroup: "g1", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新候选"}}}, + ) + if err := r.Execute(rc); err != nil { + t.Fatalf("second Execute: %v", err) + } + sess, err := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl")) + if err != nil { + t.Fatal(err) + } + loaded, err := sess.Load() + if err != nil { + t.Fatal(err) + } + joined := "" + for _, m := range loaded { + joined += m.Content + "\n" + } + if strings.Contains(joined, "旧候选") { + t.Fatalf("reseed must drop the superseded variant: %s", joined) + } + if !strings.Contains(joined, "新候选") { + t.Fatalf("reseed must keep the selected variant: %s", joined) + } +} + +// 非播种 run 的首个请求注入一次环境刷新与新命中模块;播种 run 不注入 +// (系统段已含)。用 JSON 兼容方式探请求文本。 +func joinReq(msgs []agentllm.Message) string { + var b strings.Builder + for _, m := range msgs { + b.WriteString(m.Content) + b.WriteString("\n") + } + return b.String() +} + +func TestBuildCurrentUserCarriesAttachmentsAndKnowledge(t *testing.T) { + doc := core.ParsedDocument{ + Meta: core.DocMeta{Filename: "会议.docx"}, + Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "结论先入库。"}}, + } + kn := []core.RetrievalHit{{Doc: core.KnowledgeDoc{Title: "知识", Source: "upload", Chunk: "片段"}}} + got := ctxasm.BuildCurrentUser(core.EffectiveSettings{}, core.Message{Parts: []core.Part{{Type: core.PartText, Text: "总结"}}}, []core.ParsedDocument{doc}, kn, &ctxasm.Report{}) + for _, want := range []string{"总结", "会议.docx", "结论先入库。", "[相关知识]", "片段"} { + if !strings.Contains(got.Content, want) { + t.Fatalf("current user missing %q: %s", want, got.Content) + } + } +} + +var _ = agentllm.Message{}