Merge pull request 'Make the kernel transcript the authoritative cross-turn context (quality P2-I2)' (#115) from feature/agent-quality-phase02 into main
ci / vet-and-test (push) Successful in 41s
ci / vet-and-test (push) Successful in 41s
This commit was merged in pull request #115.
This commit is contained in:
Vendored
+1
-1
Submodule deps/agent-native updated: 37c5e6fbd6...3b92cb63b4
@@ -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。
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
+42
-7
@@ -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 `你是会话压缩器。把此前的对话压缩为一份完整摘要,供后续会话作为唯一上下文继续工作。压缩期间不得调用任何工具,纯文本输出:先在 <analysis> 块内按时间顺序梳理对话(用户意图、采用的方法、关键决策、涉及的文件与数据细节、错误与修复、用户给过的反馈),再输出 <summary> 块。
|
||||
|
||||
摘要必须包含九部分:
|
||||
1. 主要请求和意图:用户要什么、为什么。
|
||||
2. 关键技术概念:领域、技术栈、核心机制。
|
||||
3. 文件和数据:涉及的文件/对象路径、关键代码或数据片段、谁改了什么。
|
||||
4. 错误和修复:出现过的问题与解法。
|
||||
5. 问题解决过程:尝试过什么、哪些有效哪些放弃。
|
||||
6. 所有用户消息:逐条保留(含澄清与反馈)。
|
||||
7. 待办任务:尚未完成的事项。
|
||||
8. 当前状态:进行到哪一步、最新状态。
|
||||
9. 下一步计划:用户或执行者提出的后续动作。`
|
||||
}
|
||||
|
||||
// toolDesc 工具描述:库覆盖(tool.<name>)优先,回落注册表文案。
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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:记忆端口迁移对照(注入文案同一形态)----
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
Reference in New Issue
Block a user