Files
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

491 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package session 实现 JSONL 会话持久化(借鉴 picoclaw 的 jsonl_backend):
// 每条消息一行,追加写;resume 时整体重放。状态文件用临时文件 + rename 原子写。
package session
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.gopher.ink/mirrors2/AutoZero/jsonl"
"git.gopher.ink/mirrors2/AutoZero/llm"
)
// dirOverride 非空时 Dir() 返回它,供宿主自定义会话存储布局;
// 默认空,走 ~/.autogo/sessions(与来源仓库 AutoGo 保持镜像)。
var dirOverride string
// SetDir 覆盖会话存储目录。须在进程启动时调用一次;并发调用不设防。
func SetDir(d string) { dirOverride = d }
// Dir 返回会话存储目录:~/.autogo/sessions。
func Dir() string {
if dirOverride != "" {
return dirOverride
}
home, err := os.UserHomeDir()
if err != nil {
return ".autogo-sessions"
}
return filepath.Join(home, ".autogo", "sessions")
}
// Session 是一个可持久化的会话。
type Session struct {
Path string
Messages []llm.Message
// Retired 是被游标退役、仍原样留在文件里的消息条数。它们不进上下文,但**没被删**
// ——这正是回退敢做的原因:活跃视图可以缩,文件不缩。
Retired int
// retired 是那些消息本身。留着它,Rewrite 才能把历史原样写回去:
// collectToolImages 那类"更新已有消息字段"的调用会整体重写文件,如果不保留
// 退役内容,一次图片缓存就把刚做的回退历史抹掉了。
retired []llm.Message
// TurnEnds 是文件里的回合结束记录(按发生顺序)。回合"为什么停"必须能在会话
// 文件本身看到:只导出 JSONL 取证时,光看最后一条消息分不清是模型说完、
// 用户停止、报错,还是被步数上限切断。
TurnEnds []TurnEnd
}
// turnEndKind 是回合结束记录行的判别值(与消息不撞:消息一定有 role)。
const turnEndKind = "autogo.turn_end"
// TurnEnd 是一次回合的退出原因。
type TurnEnd struct {
Reason string `json:"reason"` // completed | stopped | failed | max_turns
Error string `json:"error,omitempty"`
Step int `json:"step,omitempty"` // 本回合跑到第几步时结束
Ms int64 `json:"ms,omitempty"`
At int64 `json:"at,omitempty"` // unix ms
}
type turnEndRecord struct {
Kind string `json:"kind"`
Reason string `json:"reason"`
Error string `json:"error,omitempty"`
Step int `json:"step,omitempty"`
Ms int64 `json:"ms,omitempty"`
At int64 `json:"at,omitempty"`
}
// parseTurnEnd 判断这一行是不是一条回合结束记录。同样先用字节查找筛掉消息行。
func parseTurnEnd(line []byte) (TurnEnd, bool) {
if !bytes.Contains(line, []byte(`"`+turnEndKind+`"`)) {
return TurnEnd{}, false
}
var r turnEndRecord
if json.Unmarshal(line, &r) != nil || r.Kind != turnEndKind {
return TurnEnd{}, false
}
return TurnEnd{Reason: r.Reason, Error: r.Error, Step: r.Step, Ms: r.Ms, At: r.At}, true
}
// AppendTurnEnd 追加一条回合结束记录。best-effort:这是账,不该拦住回合收尾。
func (s *Session) AppendTurnEnd(t TurnEnd) {
if s.Path == "" {
return
}
t.At = time.Now().UnixMilli()
line, err := json.Marshal(turnEndRecord{Kind: turnEndKind, Reason: t.Reason, Error: t.Error, Step: t.Step, Ms: t.Ms, At: t.At})
if err != nil {
return
}
if err := s.appendLine(append(line, '\n')); err != nil {
return
}
s.TurnEnds = append(s.TurnEnds, t)
}
// cursorKind 是游标记录在 JSONL 里的判别值。它不可能与消息撞车:消息一定有 role,
// 游标一定没有。
const cursorKind = "autogo.cursor"
// cursorRecord 一行 {"kind":"autogo.cursor","keep":3}:把此刻已积累的活跃视图
// 截到前 keep 条,之后的读取继续往(缩短后的)视图上追加。
//
// 为什么不是直接改短文件(pigo/ZCode 的同一选择):会话文件是唯一事实源,靠重写
// 维护历史就意味着「重写失败 = 历史没了」。回退这一步尤其危险——被截掉的那些消息
// 是用户唯一可能想撤销的内容。改成追加一条游标之后,回退在磁盘上只是一个
// 单行 append:写一半被杀,最坏结果是这行作废、回退没生效,而不是历史丢失。
type cursorRecord struct {
Kind string `json:"kind"`
Keep int `json:"keep"`
}
// parseCursor 判断这一行是不是游标。先用一次便宜的字节查找筛掉绝大多数消息行
// (消息行可能几百 KB,逐行再解一遍 JSON 太浪费)。
func parseCursor(line []byte) (int, bool) {
if !bytes.Contains(line, []byte(`"`+cursorKind+`"`)) {
return 0, false
}
var c cursorRecord
if json.Unmarshal(line, &c) != nil || c.Kind != cursorKind {
return 0, false
}
if c.Keep < 0 {
c.Keep = 0
}
return c.Keep, true
}
// New 创建带时间戳的新会话文件名。
func New() (*Session, error) {
dir := Dir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
return &Session{Path: newName(dir, time.Now().Format("20060102-150405"))}, nil
}
// newMu 保护 issued:本进程已发出、可能还没落盘的会话路径。
var (
newMu sync.Mutex
issued = map[string]bool{}
)
// newName 在 dir 里挑一个没被占用的会话文件名。
//
// 名字只精确到秒,同一秒内连续新建会拿到同一个名字。会话文件是**第一条消息**才
// 写盘的,所以只查磁盘会漏掉"两份都还没落盘"那种撞名——那时光看 Stat 分不出来,
// 第二次"新建"经 LoadOrCreate 会把第一份(甚至某份已经写好消息的旧会话)当成
// 自己的。因此两道都查:盘上已有的文件、本进程已发出的名字,撞上就依次加 -2/-3 后缀。
func newName(dir, ts string) string {
newMu.Lock()
defer newMu.Unlock()
path := filepath.Join(dir, ts+".jsonl")
for i := 2; ; i++ {
if _, err := os.Stat(path); os.IsNotExist(err) && !issued[path] {
issued[path] = true
return path
}
path = filepath.Join(dir, fmt.Sprintf("%s-%d.jsonl", ts, i))
}
}
// sanitizeName 清理会话文件名(bot 渠道按 chat 命名,chat id 可能来自外部)。
func sanitizeName(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(name) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
s := b.String()
if len(s) > 80 {
s = s[:80]
}
return s
}
// LoadOrCreate 打开具名会话;不存在则创建(bot 渠道按 chat 持久化记忆)。
func LoadOrCreate(name string) (*Session, error) {
dir := Dir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(dir, sanitizeName(name)+".jsonl")
if _, err := os.Stat(path); err == nil {
return Load(path)
}
return &Session{Path: path}, nil
}
// Exists 报告具名会话是否已经落盘。后台通知等"会话之外的事"用它避免把
// 已删除的会话重新创建出来。
func Exists(name string) bool {
if _, ok := SafeID(name); !ok {
return false
}
_, err := os.Stat(filepath.Join(Dir(), name+".jsonl"))
return err == nil
}
// Latest 返回最近修改的会话;目录为空时新建。
func Latest() (*Session, error) {
dir := Dir()
entries, err := os.ReadDir(dir)
if err != nil || len(entries) == 0 {
return New()
}
best := ""
var bestMod time.Time
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".jsonl" {
continue
}
info, err := e.Info()
if err == nil && info.ModTime().After(bestMod) {
bestMod, best = info.ModTime(), e.Name()
}
}
if best == "" {
return New()
}
return Load(filepath.Join(dir, best))
}
// Load 重放 JSONL 文件恢复会话。
func Load(path string) (*Session, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
s := &Session{Path: path}
// 用 jsonl.Each 而不是 bufio.Scanner:后者有 64KB 行长上限,超限会让**整个会话
// 读不出来**(曾因一条 204KB 的 reasoning 消息导致会话在 WebUI 里消失)。
err = jsonl.Each(f, func(line []byte) {
if keep, ok := parseCursor(line); ok {
// 游标只缩短活跃视图,被越过的消息收进 retired 原样留着(Rewrite 要写回去)。
if keep < len(s.Messages) {
s.Retired += len(s.Messages) - keep
s.retired = append(s.retired, s.Messages[keep:]...)
s.Messages = s.Messages[:keep]
}
return
}
if te, ok := parseTurnEnd(line); ok {
s.TurnEnds = append(s.TurnEnds, te)
return
}
var m llm.Message
if json.Unmarshal(line, &m) != nil || m.Role == "" {
// 坏行、或崩溃留下的半行:跳过。没有 role 的行不是一条消息——
// 把它当空消息收进视图,会让下一次请求带着一条非法消息去撞 API。
return
}
s.Messages = append(s.Messages, m)
})
return s, err
}
// Append 追加一条消息并落盘(追加写,兼容 tail)。
func (s *Session) Append(m llm.Message) error {
s.Messages = append(s.Messages, m)
b, err := json.Marshal(m)
if err != nil {
return err
}
// 换行是内容的一部分:少了它,多条消息会被写成**一行**,整份会话解析不出来
// (曾经就是这么把自己改坏的)。
return s.appendLine(append(b, '\n'))
}
// AppendAll 一次打开文件追加多条(撤销回退时接回一段历史用)。
//
// 只有 Append 可用时也能写,但撤销一次回退要往文件里塞回几十条消息,
// 逐条 open/close 既慢又给中途崩溃留更多口子。
func (s *Session) AppendAll(msgs []llm.Message) error {
if len(msgs) == 0 {
return nil
}
var b bytes.Buffer
for _, m := range msgs {
line, err := json.Marshal(m)
if err != nil {
return err
}
b.Write(line)
b.WriteByte('\n')
}
if err := s.appendLine(b.Bytes()); err != nil {
return err
}
s.Messages = append(s.Messages, msgs...)
return nil
}
// appendLine 把一段已经带换行的 JSONL 追加到会话文件。Path 为空 = 临时会话,只改内存。
func (s *Session) appendLine(payload []byte) error {
if s.Path == "" {
return nil
}
f, err := os.OpenFile(s.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(payload)
return err
}
// RetireTo 把活跃视图截到前 keep 条,并在文件末尾追加一条游标记录。
//
// 与 Truncate 的分工是刻意的:RetireTo 是**回退**(历史留在盘上、可撤销、不进上下文),
// Truncate/Rewrite 是**清空**(/clear 那种"这些内容我不要了"的语义,真的把文件改短)。
// 写盘失败时内存一点没动——界面与文件仍然一致。
func (s *Session) RetireTo(keep int) error {
if keep < 0 {
keep = 0
}
if keep >= len(s.Messages) {
return nil // 没东西可退役,也不留一条无意义的游标
}
line, err := json.Marshal(cursorRecord{Kind: cursorKind, Keep: keep})
if err != nil {
return err
}
if err := s.appendLine(append(line, '\n')); err != nil {
return err
}
s.Retired += len(s.Messages) - keep
s.retired = append(s.retired, s.Messages[keep:]...)
s.Messages = s.Messages[:keep]
return nil
}
// Rewrite 用当前内存中的消息重写 JSONL 文件。
//
// 这是**破坏性**的一条:写完之后文件里只有活跃视图,游标与已退役的历史一起没了。
// 所以回退走 RetireTo,这里只留给"我确实不要这些内容了"的 /clear,以及更新已有
// 消息字段的场合。
//
// 必须 tmp+rename 原子替换:原来直接 os.Create 目标文件再逐条写,进程在中间被杀
// (或磁盘写满)会把整条会话截成半截——而会话文件是唯一事实源。
func (s *Session) Rewrite() error {
if s.Path == "" {
return nil
}
var b bytes.Buffer
// 先写退役的历史,再写一条 keep=0 的游标把它们从活跃视图里隔开,最后写当前视图。
// 不这么做的话,任何一次 Rewrite(例如给图片回填缓存文件名)都会把回退留下的
// 历史抹掉——文件是"更新一条消息"的载体,不该顺手删掉别的东西。
emit := func(m llm.Message) error {
line, err := json.Marshal(m)
if err != nil {
return err
}
b.Write(line)
b.WriteByte('\n')
return nil
}
for _, m := range s.retired {
if err := emit(m); err != nil {
return err
}
}
if len(s.retired) > 0 {
cur, err := json.Marshal(cursorRecord{Kind: cursorKind, Keep: 0})
if err != nil {
return err
}
b.Write(cur)
b.WriteByte('\n')
}
for _, m := range s.Messages {
if err := emit(m); err != nil {
return err
}
}
for _, te := range s.TurnEnds {
line, err := json.Marshal(turnEndRecord{Kind: turnEndKind, Reason: te.Reason, Error: te.Error, Step: te.Step, Ms: te.Ms, At: te.At})
if err != nil {
return err
}
b.Write(line)
b.WriteByte('\n')
}
tmp := s.Path + ".tmp"
if err := os.WriteFile(tmp, b.Bytes(), 0o644); err != nil {
return err
}
return os.Rename(tmp, s.Path)
}
// Reset 清空一切:活跃视图、退役历史、回合账。
//
// /clear 要的是"这些内容我不要了",所以退役的那部分也得跟着丢——否则 Rewrite
// 会把它们原样写回去,"清空"就只剩清空视图、文件却越清越肥。
func (s *Session) Reset() {
s.Messages = nil
s.retired = nil
s.Retired = 0
s.TurnEnds = nil
}
// SaveState 原子写一个状态文件(借鉴 picoclaw state.go 的 tmp+rename)。
func SaveState(path string, v any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// LoadState 读回 SaveState 写的状态文件。文件缺失或损坏返回错误,
// 由调用方决定是否降级(如项目计划缺失 = 空计划,不是致命)。
func LoadState(path string, v any) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
// String 摘要信息。
func (s *Session) String() string {
return fmt.Sprintf("session %s (%d messages)", filepath.Base(s.Path), len(s.Messages))
}
// CountPrompts 返回用户消息条数,即"第几个 prompt"意义上的回合数。
//
// 口径只有一处定义(连同 PromptBoundary),因为留档的回合号与回退的切点
// 必须是同一套数法:否则 `a2go rewind 3` 回到的不是第三次提问之前。
// 数的是 RoleUser 全部消息(含只带图片的),与写入时的计数规则一致。
func CountPrompts(msgs []llm.Message) int {
n := 0
for _, m := range msgs {
if m.Role == llm.RoleUser && !m.Synthetic {
n++
}
}
return n
}
// PromptBoundary 返回第 n 个用户消息的下标,也就是"回到第 n 个 prompt 之前"
// 应当保留的长度;找不到(n 越界)返回 -1。
//
// 切点落在用户消息上是有意的:切在 assistant 的 tool_use 与它的 tool_result
// 之间会留下没有配对的 tool_use,下一次请求被 API 直接拒掉。
func PromptBoundary(msgs []llm.Message, n int) int {
count := 0
for i := range msgs {
if msgs[i].Role != llm.RoleUser || msgs[i].Synthetic {
continue
}
count++
if count == n {
return i
}
}
return -1
}
// TailFromPrompt 返回第 n 个用户 prompt 起的尾部**副本**与切点下标(找不到返回 -1)。
//
// 它只读不动任何东西,这是刻意的顺序约束:调用方必须先用这份副本归档、成功后
// 再 RetireTo。反过来就会出现"消息已经从活跃视图摘掉、归档却失败"的窗口——
// 那正是这次改造要关掉的东西。
func (s *Session) TailFromPrompt(n int) (idx int, dropped []llm.Message) {
idx = PromptBoundary(s.Messages, n)
if idx < 0 {
return -1, nil
}
return idx, append([]llm.Message(nil), s.Messages[idx:]...)
}