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。
This commit is contained in:
Sakurasan committed 2026-10-26 00:54:46 +08:00
commit d5dac5a6fc
140 files changed
+26497

No files matched your search

+490
View File
@@ -0,0 +1,490 @@
// 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:]...)
}