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

+117
View File
@@ -0,0 +1,117 @@
// Package spill 把超长的工具结果落盘,并返回一个模型可用 read 工具取回的路径。
// 动机:视图层为省上下文会截断超长 tool result,模型因此拿不回原文;落盘后
// 截断提示里带上路径,模型需要时能读回全文。
package spill
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// maxAge 是落盘文件的保留时长,超过即在 Prune 时删除(防无限增长)。
const maxAge = 7 * 24 * time.Hour
// Dir 返回落盘目录(~/.autogo/results)。与 sessions/images/logs 同惯例;
// 取不到 HOME 时退回相对目录。
func Dir() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(".autogo", "results")
}
return filepath.Join(home, ".autogo", "results")
}
// Save 把 text 写入 dir 并返回文件路径。
// 文件名优先用消毒后的 callID(同一工具调用幂等、可读),为空时退回内容哈希。
// 已存在同路径文件则直接复用,不重复写。
func Save(dir, callID, text string) (string, error) {
if dir == "" {
return "", fmt.Errorf("spill: empty dir")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
name := sanitize(callID)
if name == "" {
sum := sha256.Sum256([]byte(text))
name = "sha-" + hex.EncodeToString(sum[:])[:16]
}
path := filepath.Join(dir, name+".txt")
if _, err := os.Stat(path); err == nil {
return path, nil // 幂等:内容已落盘
}
// tmp + rename 原子写,避免读到半截文件。
tmp, err := os.CreateTemp(dir, name+".*.tmp")
if err != nil {
return "", err
}
tmpName := tmp.Name()
if _, err := tmp.WriteString(text); err != nil {
tmp.Close()
os.Remove(tmpName)
return "", err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return "", err
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return "", err
}
return path, nil
}
// Prune 删除 dir 下超过 maxAge 的文件;目录不存在时静默返回。
func Prune(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
cutoff := time.Now().Add(-maxAge)
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil || info.ModTime().After(cutoff) {
continue
}
os.Remove(filepath.Join(dir, e.Name()))
}
}
// sanitize 把 callID 收敛为安全的文件名片段:只留字母数字与 . _ -,
// 其余替换为 _,并丢弃 . / .. 等特殊值(防路径穿越)。
func sanitize(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '_', r == '-':
b.WriteRune(r)
case r == '.':
b.WriteRune('_') // 不留 ".",杜绝 ".." 之类
default:
b.WriteRune('_')
}
}
out := b.String()
if out == "" {
return ""
}
// 限长,避免超长 callID 造成文件名过长。
if len(out) > 64 {
out = out[:64]
}
return out
}
+98
View File
@@ -0,0 +1,98 @@
package spill
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSaveAndReuse(t *testing.T) {
dir := t.TempDir()
path, err := Save(dir, "call_abc-123", "hello world")
if err != nil {
t.Fatal(err)
}
if filepath.Dir(path) != dir {
t.Errorf("unexpected dir: %s", path)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != "hello world" {
t.Errorf("content = %q", got)
}
// 幂等:同 callID 再存一次,路径相同且目录只多出 1 个文件。
path2, err := Save(dir, "call_abc-123", "hello world")
if err != nil {
t.Fatal(err)
}
if path2 != path {
t.Errorf("path changed on reuse: %s vs %s", path, path2)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
t.Errorf("want 1 file, got %d", len(entries))
}
}
func TestSaveSanitizesCallID(t *testing.T) {
dir := t.TempDir()
path, err := Save(dir, "../../etc/passwd", "x")
if err != nil {
t.Fatal(err)
}
// 必须落在 dir 内(无路径穿越)。
if filepath.Dir(path) != dir {
t.Fatalf("path escaped dir: %s", path)
}
name := filepath.Base(path)
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
t.Errorf("unsafe filename: %q", name)
}
}
func TestSaveHashFallbackAndEmptyDir(t *testing.T) {
dir := t.TempDir()
path, err := Save(dir, "", strings.Repeat("z", 10))
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(filepath.Base(path), "sha-") {
t.Errorf("empty callID should fall back to hash name, got %q", filepath.Base(path))
}
if _, err := Save("", "id", "x"); err == nil {
t.Error("empty dir must error")
}
}
func TestPruneRemovesOldFiles(t *testing.T) {
dir := t.TempDir()
old := filepath.Join(dir, "old.txt")
if err := os.WriteFile(old, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
// 把 mtime 拨到 8 天前。
past := time.Now().Add(-8 * 24 * time.Hour)
if err := os.Chtimes(old, past, past); err != nil {
t.Fatal(err)
}
fresh := filepath.Join(dir, "fresh.txt")
if err := os.WriteFile(fresh, []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
Prune(dir)
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Error("old file should have been pruned")
}
if _, err := os.Stat(fresh); err != nil {
t.Error("fresh file should survive")
}
// 目录不存在时不应 panic。
Prune(filepath.Join(dir, "nope"))
}