Files
Agent-System-Backend/internal/ctxasm/finish.go
T
weichen 4675a25c18 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.
2026-10-09 15:38:33 +00:00

309 lines
7.1 KiB
Go

package ctxasm
import (
"crypto/sha256"
"encoding/hex"
"math"
"regexp"
"strconv"
"strings"
"sync"
agentllm "agent-native/runtime/llm"
"agent-native/runtime/reminders"
core "agent-system-core"
)
// Injection 是一条在组装末尾才决定是否注入的条件条目。
type Injection struct {
Keys []string
Secondary string
Logic string
Content string
Constant bool
ScanDepth int
BudgetChars int
}
// Tail 是前缀稳定之后附在末尾的个人指令、会话要求和条件注入。
type Tail struct {
Instructions string
Note string
Entries []Injection
History []core.Message
Window int
}
var calib struct {
mu sync.Mutex
est int
actual int
}
// NotePromptUsage 用上一请求的真实 prompt token 校准后续估算。
func NotePromptUsage(est, actual int) {
if est <= 0 || actual <= 0 {
return
}
calib.mu.Lock()
calib.est = est
calib.actual = actual
calib.mu.Unlock()
}
func scaleTokens(est int) int {
calib.mu.Lock()
defer calib.mu.Unlock()
if est <= 0 || calib.est <= 0 || calib.actual <= 0 {
return est
}
return est * calib.actual / calib.est
}
// EstimateTokens 用 UTF-8 字节数 / 3.35 估算 token。
func EstimateTokens(text string) int {
n := len([]byte(text))
if n == 0 {
return 0
}
return int(math.Ceil(float64(n) / 3.35))
}
func visibleHistory(history []core.Message) []core.Message {
best := map[string]int{}
for i, m := range history {
if m.Excluded || m.VariantGroup == "" {
continue
}
prev, ok := best[m.VariantGroup]
if !ok || m.VariantIndex >= history[prev].VariantIndex {
best[m.VariantGroup] = i
}
}
out := make([]core.Message, 0, len(history))
for i, m := range history {
if m.Excluded {
continue
}
if m.VariantGroup != "" && best[m.VariantGroup] != i {
continue
}
out = append(out, m)
}
return out
}
// FinishAssembly 把末尾提醒附上、按窗口丢掉最老的历史,并写分段估算与前缀哈希。
func FinishAssembly(msgs []agentllm.Message, rep *Report, tail Tail) ([]agentllm.Message, *Report) {
if rep == nil {
rep = &Report{}
}
notes := tailNotes(tail)
reserve := 0
for _, n := range notes {
reserve += EstimateTokens(n) + 8
}
msgs = trimOldest(msgs, tail.Window, reserve, rep)
msgs = reminders.Attach(msgs, notes)
fillEstimate(msgs, rep)
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 != "" {
notes = append(notes, "个人指令:\n"+text)
}
if text := strings.TrimSpace(tail.Note); text != "" {
notes = append(notes, "本会话要求:\n"+text)
}
for _, e := range tail.Entries {
if strings.TrimSpace(e.Content) == "" {
continue
}
if !e.Constant && !entryHit(e, tail.History) {
continue
}
body := e.Content
if e.BudgetChars > 0 {
r := []rune(body)
if len(r) > e.BudgetChars {
body = string(r[:e.BudgetChars])
}
}
notes = append(notes, body)
}
return notes
}
func entryHit(e Injection, history []core.Message) bool {
if len(e.Keys) == 0 {
return false
}
depth := e.ScanDepth
if depth <= 0 {
depth = 4
}
start := 0
if len(history) > depth {
start = len(history) - depth
}
var b strings.Builder
for _, m := range history[start:] {
for _, p := range m.Parts {
if p.Type == core.PartText {
b.WriteString(p.Text)
b.WriteByte('\n')
}
}
}
text := b.String()
primary := matchAny(e.Keys, text)
sec := strings.TrimSpace(e.Secondary)
if sec == "" {
return primary
}
secondary := matchOne(sec, text)
switch e.Logic {
case "or":
return primary || secondary
case "not":
return primary && !secondary
default:
return primary && secondary
}
}
func matchAny(keys []string, text string) bool {
for _, k := range keys {
if matchOne(k, text) {
return true
}
}
return false
}
func matchOne(key, text string) bool {
key = strings.TrimSpace(key)
if key == "" {
return false
}
if len(key) > 2 && strings.HasPrefix(key, "/") && strings.HasSuffix(key, "/") {
re, err := regexp.Compile(key[1 : len(key)-1])
if err == nil {
return re.MatchString(text)
}
}
return strings.Contains(strings.ToLower(text), strings.ToLower(key))
}
func trimOldest(msgs []agentllm.Message, window, reserve int, rep *Report) []agentllm.Message {
if window <= 0 {
return msgs
}
limit := window - reserve
if limit < 1 {
limit = 1
}
dropped := 0
for estimateAll(msgs) > limit {
start, end := oldestDropRange(msgs)
if start < 0 {
break
}
dropped += end - start
msgs = append(msgs[:start], msgs[end:]...)
}
if dropped > 0 {
rep.Truncations = append(rep.Truncations, fmtDropped(dropped))
}
return msgs
}
func fmtDropped(n int) string {
return "按上下文窗口丢掉最早的 " + itoa(n) + " 条历史"
}
func itoa(n int) string { return strconv.Itoa(n) }
// oldestDropRange 返回下一次丢弃的区间 [start, end)(找不到返回 -1,-1)。
// 丢弃按工具组对齐:带 tool_calls 的 assistant 与其连续 tool 结果同退,
// 开头的孤立 tool 结果组整组同退——裁切后既不剩无结果的调用,
// 也不剩无调用的结果(与库层 alignToolTail 同语义、方向相反)。
// 组越过最后一条(当前轮)时不再丢弃:窗口允许略超,配对不可破坏。
func oldestDropRange(msgs []agentllm.Message) (int, int) {
if len(msgs) < 3 {
return -1, -1
}
// 保留开头的系统前缀和最后一条(当前轮)。
start := 0
for start < len(msgs) && msgs[start].Role == agentllm.RoleSystem {
start++
}
if start >= len(msgs)-1 {
return -1, -1
}
end := start + 1
m := msgs[start]
switch {
case m.Role == agentllm.RoleAssistant && len(m.ToolCalls) > 0:
// assistant 及其连续 tool 结果整组同退。
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
end++
}
case m.Role == agentllm.RoleTool:
// 开头即孤立 tool 结果(其 assistant 必已被丢或不存在):整组同退。
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
end++
}
}
if end > len(msgs)-1 {
return -1, -1
}
return start, end
}
func estimateAll(msgs []agentllm.Message) int {
n := 0
for _, m := range msgs {
n += EstimateTokens(m.Content)
}
return n
}
func fillEstimate(msgs []agentllm.Message, rep *Report) {
sys, hist, cur := 0, 0, 0
var prefix strings.Builder
last := len(msgs) - 1
for i, m := range msgs {
tok := EstimateTokens(m.Content)
switch {
case m.Role == agentllm.RoleSystem && i < last:
sys += tok
prefix.WriteString(m.Content)
prefix.WriteByte('\n')
case i == last:
cur += tok
default:
hist += tok
}
}
rep.Sections = []Section{
{Name: "system", EstTokens: scaleTokens(sys)},
{Name: "history", EstTokens: scaleTokens(hist)},
{Name: "current", EstTokens: scaleTokens(cur)},
}
sum := sha256.Sum256([]byte(prefix.String()))
rep.PrefixHash = hex.EncodeToString(sum[:8])
rep.EstChars = scaleTokens(sys + hist + cur)
}
// Section 是一段上下文的估算。
type Section struct {
Name string `json:"name"`
EstTokens int `json:"estTokens"`
}