Make the kernel transcript the authoritative cross-turn context

Why: Every turn rebuilt context from UI messages with 160-char tool-result
summaries and truncated arguments (quality diagnosis root cause 1). The kernel
session transcript already persists full-fidelity messages; P2-I2 switches the
direct path to it as the authority, per the approved plan (Documents
2026-10-09 §4 P2, user ruling 2).

First run (or after structural divergence) seeds the transcript once with the
full ctxasm assembly; later runs pass only the current user message. Dynamic
context - tail notes (personal instructions, session notes, world-book
entries), env update (date, workspace files) and newly hit task-layer prompt
modules - refreshes once per non-seed run via Reminders, so the frozen seed
system prompt stays accurate. Structural divergence (excluded messages,
variant switching, regeneration) changes a fingerprint and reseeds the
transcript from the message store - the regenerate/exclude/variant mapping.
Legacy transcripts without a fingerprint are adopted, not wiped, preserving
crash-pairing repair. Near the window threshold a 9-section compaction
(aux.session-compact, extractive fallback) summarizes older turns with the
kernel's append-only compaction entry; the kernel request-side micro-clear
still bounds within-run spikes. Backend's nested agent-native pin moves to
the reasoning-capture/replay kernel head.

Evidence: SELF - new tests TestContextRevisionStableOnAppendAndChangesOnDivergence,
TestTranscriptReseedsOnVariantSwitch, TestBuildCurrentUserCarriesAttachmentsAndKnowledge;
TestDirectRunSeedsStoreHistoryOnce updated to authoritative semantics; go vet
+ go test ./... pass; live before/after on the golden task set recorded on
the phase02 task card per the policy clause.

Impact: Direct conversations with a workspace sandbox now carry full tool
results, arguments and attachment text across turns; MiniMax thinking replays
from the transcript via the kernel change. No-conversation fallback keeps the
pre-I2 assembly path.
This commit is contained in:
2026-10-09 15:38:33 +00:00
parent 9fe2a592f3
commit 4675a25c18
9 changed files with 483 additions and 35 deletions
+20 -7
View File
@@ -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 var sb strings.Builder
for _, p := range currentUser.Parts { for _, p := range currentUser.Parts {
if p.Type == core.PartText { if p.Type == core.PartText {
@@ -125,23 +135,26 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
text := doc.PlainText() text := doc.PlainText()
if runes := []rune(text); len(runes) > limit { if runes := []rune(text); len(runes) > limit {
text = string(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) 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 { if len(knowledge) > 0 {
sb.WriteString("\n\n[相关知识]\n") sb.WriteString("\n\n[相关知识]\n")
for i, h := range knowledge { 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) 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()}) return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()}
fillEstimate(msgs, rep)
return msgs, rep
} }
// maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。 // maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。
+3
View File
@@ -109,6 +109,9 @@ func FinishAssembly(msgs []agentllm.Message, rep *Report, tail Tail) ([]agentllm
return msgs, rep return msgs, rep
} }
// TailNotes 导出尾注渲染(转录权威模式下由调用方经 Reminders 逐轮注入)。
func TailNotes(tail Tail) []string { return tailNotes(tail) }
func tailNotes(tail Tail) []string { func tailNotes(tail Tail) []string {
var notes []string var notes []string
if text := strings.TrimSpace(tail.Instructions); text != "" { if text := strings.TrimSpace(tail.Instructions); text != "" {
+42 -7
View File
@@ -69,8 +69,10 @@ const (
toolCardCommit = "card_commit" toolCardCommit = "card_commit"
) )
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目。 // resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目
func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string) { // 与本轮命中的 task 层模块(转录权威模式下系统段只在播种时存在,后续轮的新命中
// 模块经 Reminders 注入)。
func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string, []core.PromptBlock) {
userText := currentUserText(rc) userText := currentUserText(rc)
atts := rc.Attachments() atts := rc.Attachments()
names := make([]string, len(atts)) 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, Text: userText, HasAttachments: len(atts) > 0,
}, env, prof.Owner) }, env, prof.Owner)
if err != nil { if err != nil {
return nil, []string{"prompt(库解析失败:" + err.Error() + ")"} return nil, []string{"prompt(库解析失败:" + err.Error() + ")"}, nil
} }
// 卡流注入(D39/D42):ui.cardflow 开启时附加 style.cardflow 模块正文。 // 卡流注入(D39/D42):ui.cardflow 开启时附加 style.cardflow 模块正文。
// 不走 PromptModules(向空列表追加会翻转 pick 的"显式引用"语义),独立成块。 // 不走 PromptModules(向空列表追加会翻转 pick 的"显式引用"语义),独立成块。
@@ -109,10 +111,14 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
} }
} }
msgs := make([]agentllm.Message, 0, len(blocks)) msgs := make([]agentllm.Message, 0, len(blocks))
var obs []string var taskBlocks []core.PromptBlock
for _, b := range blocks { for _, b := range blocks {
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.Body}) 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 { for _, h := range hits {
if h.Injected { if h.Injected {
obs = append(obs, fmt.Sprintf("prompt(%s v%d · %s)", h.ModuleID, versionOf(blocks, h.ModuleID), h.Reason)) 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)) obs = append(obs, fmt.Sprintf("skill-yield(%s · %s)", h.ModuleID, h.Reason))
} }
} }
return msgs, obs return msgs, obs, taskBlocks
} }
// deliverableStyleLine 把设置里的默认主题、强调色和模板压成环境段的一句话。 // deliverableStyleLine 把设置里的默认主题、强调色和模板压成环境段的一句话。
@@ -139,6 +145,28 @@ func deliverableStyleLine(eff core.EffectiveSettings) string {
return line 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 `你是会话压缩器。把此前的对话压缩为一份完整摘要,供后续会话作为唯一上下文继续工作。压缩期间不得调用任何工具,纯文本输出:先在 <analysis> 块内按时间顺序梳理对话(用户意图、采用的方法、关键决策、涉及的文件与数据细节、错误与修复、用户给过的反馈),再输出 <summary> 块。
摘要必须包含九部分:
1. 主要请求和意图:用户要什么、为什么。
2. 关键技术概念:领域、技术栈、核心机制。
3. 文件和数据:涉及的文件/对象路径、关键代码或数据片段、谁改了什么。
4. 错误和修复:出现过的问题与解法。
5. 问题解决过程:尝试过什么、哪些有效哪些放弃。
6. 所有用户消息:逐条保留(含澄清与反馈)。
7. 待办任务:尚未完成的事项。
8. 当前状态:进行到哪一步、最新状态。
9. 下一步计划:用户或执行者提出的后续动作。`
}
// toolDesc 工具描述:库覆盖(tool.<name>)优先,回落注册表文案。 // toolDesc 工具描述:库覆盖(tool.<name>)优先,回落注册表文案。
func (l *LLMRunner) toolDesc(owner, name, fallback string) string { func (l *LLMRunner) toolDesc(owner, name, fallback string) string {
if l.Prompts != nil { if l.Prompts != nil {
@@ -232,8 +260,9 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
currentUser, _ := lastAddressed(hist) currentUser, _ := lastAddressed(hist)
var sys []agentllm.Message var sys []agentllm.Message
var promptObs []string var promptObs []string
var taskBlocks []core.PromptBlock
if l.Prompts != nil { if l.Prompts != nil {
sys, promptObs = l.resolveSystem(rc, route.Model) sys, promptObs, taskBlocks = l.resolveSystem(rc, route.Model)
} }
// 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。 // 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。
resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) } 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"), MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens"),
MessageShape: eff.GetString("context.messageShape", "merge"), 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 { func permissionModeOf(rc RunCtx) string {
+79 -17
View File
@@ -92,7 +92,7 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo
return t.run(ctx, args), nil 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} st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow}
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。 // 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok { 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} user := agentllm.Message{Role: agentllm.RoleUser}
if len(msgs) > 0 { if len(msgs) > 0 {
user = msgs[len(msgs)-1] 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 { wrapped := func(ctx context.Context, model string, messages []agentllm.Message, _ []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
if streamOpts.MaxTokens > 0 { if streamOpts.MaxTokens > 0 {
@@ -170,21 +168,59 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
// 运行内被清理的旧工具结果完整落盘(占位写路径,模型可 cat 回看)—— // 运行内被清理的旧工具结果完整落盘(占位写路径,模型可 cat 回看)——
// 此前占位只写「见会话记录」,而模型没有读会话记录的工具。 // 此前占位只写「见会话记录」,而模型没有读会话记录的工具。
var resultsDir string var resultsDir string
if sandbox := conversationSandbox(rc); sandbox != "" { sandbox := conversationSandbox(rc)
if sandbox != "" {
resultsDir = filepath.Join(sandbox, ".agent", "results") resultsDir = filepath.Join(sandbox, ".agent", "results")
} }
// 会话转录(N3 WP2):产品会话 run 接入意图日志与恢复配对—— // 会话转录为权威上下文(质量 P2-I2):内核 Session 承载跨轮历史——
// JournalOnly 模式:journal 只作日志,上下文仍由 ctxasm 组装(产品管线 // 完整工具结果/参数/附件原文随转录持久,不再由界面消息按 160 字摘要
// 不冻结);恢复写入的配对摘要以系统消息注入。打开失败降级为无 // 重建。首轮(或重播种)用 ctxasm 整段组装做一次性种子;此后每轮只传
// Session(旧行为,不阻断对话)。 // 当前用户消息,系统段与动态环境经 Reminders 刷新。
// 结构分歧(排除消息 / 变体切换 / 重新生成)改变指纹 ⇒ 重播种转录,
// 保证转录与消息库一致(regenerate/exclude/候选选择的等价映射)。
// 打开失败降级为无 Session(旧行为:ctxasm 整段组装,不阻断对话)。
var sessStore *agentsession.Store var sessStore *agentsession.Store
if sandbox := conversationSandbox(rc); sandbox != "" { seedRun := true
if opened, oerr := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl")); oerr == nil { 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 sessStore = opened
} else { } else {
log.Printf("[runtime] open session transcript: %v", oerr) 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 := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome { bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop { if c.Event != hooks.Stop {
@@ -200,7 +236,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
Model: model, Model: model,
History: history, History: history,
Session: sessStore, Session: sessStore,
JournalOnly: sessStore != nil, JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及
Tools: reg, Tools: reg,
Stream: wrapped, Stream: wrapped,
MaxTurns: maxTurns, MaxTurns: maxTurns,
@@ -214,12 +250,38 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
ResultsDir: resultsDir, ResultsDir: resultsDir,
OnEvent: st.onEvent, OnEvent: st.onEvent,
Hooks: bus, Hooks: bus,
Reminders: st.reminders, Reminders: func(turn int) []string {
PermissionAsk: PermissionAskOf(rc), var out []string
Trace: l.Trace, out = append(out, st.reminders(turn)...)
AgentName: agentNameOf(rc.Profile()), // 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
Memory: st.memoryStore, // 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。
RetryAttempts: retryAttemptsOf(eff), 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 { UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
return st.runTool(call.Name, call.Arguments) return st.runTool(call.Name, call.Arguments)
}, },
+7 -3
View File
@@ -69,8 +69,8 @@ func TestDirectRunPairsAfterKillSeed(t *testing.T) {
} }
} }
// JournalOnly 语义:上下文始终由 store 历史组装(每跑都在、恰好一次、 // 转录权威语义(质量 P2-I2):首次播种把组装历史持久进转录且恰好一次;
// 不丢不重);journal 不吞种子历史。 // 后续 run 由转录承载,不重复播种。
func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) { func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) {
sandbox := t.TempDir() sandbox := t.TempDir()
rc := &fakeRunCtx{ rc := &fakeRunCtx{
@@ -118,11 +118,15 @@ func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
count := 0
for _, m := range loaded { for _, m := range loaded {
if m.Content == "历史一" { if m.Content == "历史一" {
t.Fatalf("journal 不得吞种子历史:%#v", loaded) count++
} }
} }
if count != 1 {
t.Fatalf("转录应恰好一次包含种子历史「历史一」,得到 %d:%#v", count, loaded)
}
} }
// ---- N3 WP5:记忆端口迁移对照(注入文案同一形态)---- // ---- N3 WP5:记忆端口迁移对照(注入文案同一形态)----
+110
View File
@@ -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()
}
+108
View File
@@ -0,0 +1,108 @@
package runtime
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"sort"
core "agent-system-core"
)
// seedMeta 是会话转录旁的播种状态:<transcript>.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
}
+113
View File
@@ -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{}