Add session compaction, metadata, and crash-safe transcript loading

Why: long multi-turn executions grow the JSONL transcript until they
hit provider context limits (parity-matrix #11), and a crashed append
left a partial line that made the whole session unloadable
(parity-matrix #12 resume semantics).

Evidence: go vet ./... clean; go test ./... 13 packages ok. Session
cases: crash-residue tail skipped while mid-file corruption still
errors, Stats line/byte counts, meta sidecar roundtrip (missing meta
nil), Compact rewriting system+summary+keepLastN atomically with no
tmp residue, no-op on short transcripts, summarizer error leaving the
file untouched. Adapter case: threshold-triggered compaction runs
before the loop and the summary marker reaches both the transcript
and the model's context.

Impact: session gains Stats/SaveMeta/ReadMeta/Compact; native.Options
gains Compact *CompactConfig (default summarizer is one tool-less
stream call; compaction failure logs and continues - optimization,
never a silent failure); session meta sidecar records model/scope/
exec id per execution.

Refs: docs/parity-matrix.md #11/#12 (Batch3 scope).
This commit is contained in:
2026-10-01 05:51:46 +00:00
parent b73611939e
commit 7710be2db4
5 changed files with 469 additions and 3 deletions
+87
View File
@@ -45,12 +45,24 @@ type Options struct {
MCP *mcp.Manager
// SkillsRoots 可选:全局技能根目录;执行时自动并入工作区 .agent/skills/。
SkillsRoots []string
// Compact 可选:转录超过阈值时把头部历史压成摘要(长会话上下文治理)。
Compact *CompactConfig
// Log 可选。nil 时丢弃日志。
Log *logsys.Logger
// ExecutionID 写入日志,不参与执行控制。
ExecutionID string
}
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
type CompactConfig struct {
// ThresholdChars 触发阈值(按字节数近似),默认 300_000。
ThresholdChars int
// KeepLastN 压缩后保留的最近消息条数,默认 8。
KeepLastN int
// Summarizer 可选注入摘要函数;缺省用后端 stream 做一次无工具调用。
Summarizer func(ctx context.Context, messages []llm.Message) (string, error)
}
// Backend 以自研内核实现 exec.Backend。
type Backend struct {
opts Options
@@ -168,6 +180,13 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
client := &llm.Client{BaseURL: b.opts.APIBase, APIKey: b.opts.APIKey}
stream = client.Stream
}
_ = store.SaveMeta(map[string]string{
"model": model,
"scope": req.SessionScope,
"exec_id": req.ExecutionID,
"updated": time.Now().UTC().Format(time.RFC3339),
})
b.maybeCompact(ctx, store, stream, model)
timeout := req.Timeout
if timeout <= 0 {
@@ -301,6 +320,74 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
return runRes, err
}
// maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质:
// 失败不阻断执行(按原文继续),但必须留日志,不静默。
func (b *Backend) maybeCompact(ctx context.Context, store *session.Store, stream llm.StreamFunc, model string) {
cfg := b.opts.Compact
if cfg == nil {
return
}
threshold := int64(cfg.ThresholdChars)
if threshold <= 0 {
threshold = 300_000
}
keep := cfg.KeepLastN
if keep <= 0 {
keep = 8
}
if store.Stats().Bytes < threshold {
return
}
summarize := cfg.Summarizer
if summarize == nil {
summarize = defaultSummarizer(stream, model)
}
done, err := store.Compact(keep, func(messages []llm.Message) (string, error) {
return summarize(ctx, messages)
})
if err != nil {
b.logger().Warn("session compaction failed; continuing with full transcript", "error", err.Error())
return
}
if done {
b.logger().Info("session compacted", "keep_last", keep)
}
}
// defaultSummarizer 用后端 stream 做一次无工具调用,把历史压成摘要。
func defaultSummarizer(stream llm.StreamFunc, model string) func(ctx context.Context, messages []llm.Message) (string, error) {
return func(ctx context.Context, messages []llm.Message) (string, error) {
var b strings.Builder
for _, m := range messages {
b.WriteString(string(m.Role))
b.WriteString(": ")
b.WriteString(m.Content)
b.WriteString("\n")
if b.Len() > 120_000 {
break
}
}
prompt := "把以下智能体对话历史压缩成摘要:保留全部关键事实、已做决定、文件变更与未决问题;中文;不超过 800 字;只输出摘要正文。\n\n" + b.String()
var out strings.Builder
for ev := range stream(ctx, model, []llm.Message{{Role: llm.RoleUser, Content: prompt}}, nil, llm.StreamOptions{}) {
switch ev.Kind {
case llm.KindTextDelta:
out.WriteString(ev.Text)
case llm.KindError:
return "", ev.Err
}
}
if strings.TrimSpace(out.String()) == "" {
return "", errString("summarizer produced empty output")
}
return out.String(), nil
}
}
type errString string
func (e errString) Error() string { return string(e) }
// resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/
// 迭代上限/非法请求——重试必须换新会话而非续跑)。
var resumeUnsafeMarkers = []string{
+69
View File
@@ -0,0 +1,69 @@
package native
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
)
func TestBackendCompactBeforeRun(t *testing.T) {
dir := t.TempDir()
// 预置一份超阈值的长转录(系统消息 + 12 条历史)。
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
if err := os.MkdirAll(filepath.Dir(transcript), 0o755); err != nil {
t.Fatal(err)
}
var b strings.Builder
b.WriteString(`{"role":"system","content":"sys"}` + "\n")
for i := 0; i < 12; i++ {
b.WriteString(`{"role":"user","content":"` + strings.Repeat("x", 400) + `"}` + "\n")
}
if err := os.WriteFile(transcript, []byte(b.String()), 0o644); err != nil {
t.Fatal(err)
}
var sawSummary bool
// 注入式捕获:验证 Run 收到的消息里有摘要标记。
bk2 := New(Options{
Model: "fake",
Stream: captureFirstStream(&sawSummary),
Compact: &CompactConfig{ThresholdChars: 1000, KeepLastN: 3, Summarizer: func(ctx context.Context, messages []llm.Message) (string, error) {
return "预置历史摘要", nil
}},
})
res, err := bk2.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "继续", Targets: []string{"output.md"},
ToolProfile: exec.ToolProfileReasoning,
})
if err != nil {
t.Fatal(err)
}
if res.Success && !sawSummary {
t.Fatal("compaction should have run before the loop")
}
data, _ := os.ReadFile(transcript)
if !strings.Contains(string(data), "[conversation summary]") {
t.Fatalf("transcript should contain the summary marker, got:\n%.200s", data)
}
}
func captureFirstStream(saw *bool) llm.StreamFunc {
return func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
for _, m := range messages {
if strings.Contains(m.Content, "[conversation summary]") {
*saw = true
}
}
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "压缩后续写完成", StopReason: llm.StopEnd}}
}()
return ch
}
}