Import agent-squad as a standalone repository.
ci / vet-and-test (push) Failing after 3s
ci / docker-checkout-hostgateway (push) Failing after 3s

Pin Agent-Native as a submodule so the module builds without the multi-repo sibling path.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-29 12:38:35 +00:00
co-authored by Cursor
commit bfec3c8930
42 changed files with 8202 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"agent-squad/squad"
)
// FileEventSink 事件持久化装饰器:把引擎事件按行追加到
// runs/<run_id>/events.jsonl(每行带自增 seq,天然即回放顺序),
// 落盘前做秘密形状脱敏,然后转发给下游(FanoutSink → SSE)。
// 机制参考 Multica 的 (task_id, seq) 消息表 + 写前 redact,文件版实现。
type FileEventSink struct {
DataDir string
Next squad.ProgressSink
mu sync.Mutex
files map[string]*os.File
seqs map[string]int64
}
// NewFileEventSink 创建事件落盘 sink。
func NewFileEventSink(dataDir string, next squad.ProgressSink) *FileEventSink {
return &FileEventSink{DataDir: dataDir, Next: next, files: map[string]*os.File{}, seqs: map[string]int64{}}
}
// Emit 落盘(脱敏)并转发。
func (s *FileEventSink) Emit(ev squad.Event) {
defer func() {
if s.Next != nil {
s.Next.Emit(ev)
}
}()
if ev.RunID == "" {
return
}
if ev.At.IsZero() {
ev.At = time.Now()
}
s.mu.Lock()
defer s.mu.Unlock()
f, ok := s.files[ev.RunID]
if !ok {
dir := filepath.Join(s.DataDir, "runs", ev.RunID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
fp, err := os.OpenFile(filepath.Join(dir, "events.jsonl"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
f = fp
s.files[ev.RunID] = f
}
s.seqs[ev.RunID]++
line := persistedEvent{Seq: s.seqs[ev.RunID], Type: ev.Type, StepID: ev.StepID,
Role: ev.Role, Text: Redact(ev.Text), At: ev.At}
if len(ev.Data) > 0 {
line.Data = json.RawMessage(Redact(string(ev.Data)))
}
data, err := json.Marshal(line)
if err != nil {
return
}
_, _ = f.Write(append(data, '\n'))
}
// Close 关闭全部文件句柄。
func (s *FileEventSink) Close() {
s.mu.Lock()
defer s.mu.Unlock()
for _, f := range s.files {
_ = f.Close()
}
s.files = map[string]*os.File{}
}
type persistedEvent struct {
Seq int64 `json:"seq"`
Type string `json:"type"`
StepID string `json:"step_id,omitempty"`
Role string `json:"role,omitempty"`
Text string `json:"text,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
At time.Time `json:"at"`
}
// secret 形状:API key(sk-/api key 风格长串)与 Bearer 凭证。
var (
reSKKey = regexp.MustCompile(`(?:sk|rk|pk)-[A-Za-z0-9_\-]{12,}`)
reBearer = regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._\-]{12,}`)
reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key["':=\s]+)[A-Za-z0-9_\-\.]{12,}`)
reJWT = regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`)
)
// Redact 脱敏:把秘密形状子串替换为 ***(宁可多脱,不漏一个)。
func Redact(s string) string {
if s == "" {
return s
}
s = reJWT.ReplaceAllString(s, "***")
s = reSKKey.ReplaceAllString(s, "***")
s = reBearer.ReplaceAllString(s, "Bearer ***")
s = reAPIKey.ReplaceAllString(s, "${1}***")
if strings.Contains(strings.ToLower(s), "password") {
s = regexp.MustCompile(`(?i)(password["':=\s]+)[^\s"',}]+`).ReplaceAllString(s, "${1}***")
}
return s
}
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-squad/squad"
)
func TestFileEventSinkPersistAndRedact(t *testing.T) {
dir := t.TempDir()
var got []squad.Event
next := squad.ProgressFunc(func(ev squad.Event) { got = append(got, ev) })
sink := NewFileEventSink(dir, next)
defer sink.Close()
sink.Emit(squad.Event{Type: squad.EvRunStarted, RunID: "run_x", Text: "开始 sk-api-8abcdefgh1234567890"})
sink.Emit(squad.Event{Type: squad.EvDetail, RunID: "run_x", StepID: "s1",
Text: "Bearer abcdefghijklmnopqrst",
Data: json.RawMessage(`{"note":"api_key=abcdefghijklmnopqrstuvwxyz123456"}`)})
sink.Emit(squad.Event{Type: "no_run"}) // 无 RunID:不落盘但转发
// 转发不丢。
if len(got) != 3 {
t.Fatalf("forwarded events want 3, got %d", len(got))
}
// 落盘 2 行,带 seq。
f, err := os.Open(filepath.Join(dir, "runs", "run_x", "events.jsonl"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
sc := bufio.NewScanner(f)
var lines []persistedEvent
for sc.Scan() {
var pe persistedEvent
if err := json.Unmarshal(sc.Bytes(), &pe); err != nil {
t.Fatal(err)
}
lines = append(lines, pe)
}
if len(lines) != 2 || lines[0].Seq != 1 || lines[1].Seq != 2 {
t.Fatalf("seq/lines wrong: %+v", lines)
}
// 脱敏生效。
if strings.Contains(lines[0].Text, "sk-api-8") || !strings.Contains(lines[0].Text, "***") {
t.Fatalf("sk key not redacted: %q", lines[0].Text)
}
if strings.Contains(lines[1].Text, "abcdefghijklmnopqrst") {
t.Fatalf("bearer not redacted: %q", lines[1].Text)
}
if strings.Contains(string(lines[1].Data), "abcdefghijklmnopqrstuvwxyz123456") {
t.Fatalf("api key in data not redacted: %s", lines[1].Data)
}
}
func TestRedactShapes(t *testing.T) {
cases := map[string]string{
"key sk-proj-1234567890abcdef": "key ***",
"Authorization: Bearer abcdef1234567890abc": "Authorization: Bearer ***",
`{"api_key":"abcdefghijklmnop12"}`: `{"api_key":"***"}`,
"eyJhbGciOiJIU.eyJzdWIiOiJI.SflKxwRJSMeK": "***",
`{"password":"hunter2secret"}`: `{"password":"***"}`,
"普通文本不受影响 sk-short": "普通文本不受影响 sk-short",
}
for in, want := range cases {
if got := Redact(in); got != want {
t.Errorf("Redact(%q)=%q, want %q", in, got, want)
}
}
_ = time.Now
}
+110
View File
@@ -0,0 +1,110 @@
// Package store 提供 agent-squad 的文件持久化:
// roles.d/*.json、blueprints.d/*.json;运行状态由引擎直接管理 runs/<id>/。
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"agent-squad/squad"
)
// FileStore 数据目录级文件存储。
type FileStore struct {
DataDir string
}
// New 创建文件存储并确保目录存在。
func New(dataDir string) (*FileStore, error) {
s := &FileStore{DataDir: dataDir}
for _, d := range []string{filepath.Join(dataDir, "roles.d"), filepath.Join(dataDir, "blueprints.d")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err
}
}
return s, nil
}
// LoadRoles 加载 roles.d/*.json 到注册表,并挂接增量落盘钩子。
// 文件名(去扩展名)不作为权威,以 JSON 内 role 字段为准。
func (s *FileStore) LoadRoles(reg *squad.RoleRegistry) error {
entries, err := os.ReadDir(filepath.Join(s.DataDir, "roles.d"))
if err != nil {
return err
}
for _, ent := range entries {
if ent.IsDir() || (!strings.HasSuffix(ent.Name(), ".json")) {
continue
}
data, err := os.ReadFile(filepath.Join(s.DataDir, "roles.d", ent.Name()))
if err != nil {
return err
}
var spec squad.RoleSpec
if err := json.Unmarshal(data, &spec); err != nil {
return fmt.Errorf("roles.d/%s: %w", ent.Name(), err)
}
if err := reg.Register(spec); err != nil {
return fmt.Errorf("roles.d/%s: %w", ent.Name(), err)
}
}
reg.OnChange = s.SaveRole
return nil
}
// SaveRole 落盘单个角色。
func (s *FileStore) SaveRole(spec squad.RoleSpec) {
data, err := json.MarshalIndent(spec, "", " ")
if err != nil {
return
}
_ = os.WriteFile(filepath.Join(s.DataDir, "roles.d", spec.Role+".json"), data, 0o644)
}
// DeleteRole 删除角色文件(注册表删除由调用方完成)。
func (s *FileStore) DeleteRole(key string) {
_ = os.Remove(filepath.Join(s.DataDir, "roles.d", key+".json"))
}
// LoadBlueprints 加载 blueprints.d/*.json 并挂接落盘钩子。
func (s *FileStore) LoadBlueprints(bps *squad.BlueprintRegistry) error {
entries, err := os.ReadDir(filepath.Join(s.DataDir, "blueprints.d"))
if err != nil {
return err
}
for _, ent := range entries {
if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(s.DataDir, "blueprints.d", ent.Name()))
if err != nil {
return err
}
var bp squad.Blueprint
if err := json.Unmarshal(data, &bp); err != nil {
return fmt.Errorf("blueprints.d/%s: %w", ent.Name(), err)
}
if err := bps.Register(bp); err != nil {
return fmt.Errorf("blueprints.d/%s: %w", ent.Name(), err)
}
}
bps.OnChange = s.SaveBlueprint
return nil
}
// SaveBlueprint 落盘单个蓝图。
func (s *FileStore) SaveBlueprint(bp squad.Blueprint) {
data, err := json.MarshalIndent(bp, "", " ")
if err != nil {
return
}
_ = os.WriteFile(filepath.Join(s.DataDir, "blueprints.d", bp.Key+".json"), data, 0o644)
}
// DeleteBlueprint 删除蓝图文件。
func (s *FileStore) DeleteBlueprint(key string) {
_ = os.Remove(filepath.Join(s.DataDir, "blueprints.d", key+".json"))
}
+72
View File
@@ -0,0 +1,72 @@
package store
import (
"os"
"path/filepath"
"testing"
"agent-squad/squad"
)
func TestRolesPersistAcrossReload(t *testing.T) {
dir := t.TempDir()
st, err := New(dir)
if err != nil {
t.Fatal(err)
}
reg1 := squad.NewRoleRegistry()
if err := st.LoadRoles(reg1); err != nil {
t.Fatal(err)
}
if err := reg1.Register(squad.RoleSpec{Role: "researcher", Backend: "stub", Keywords: []string{"调研"}}); err != nil {
t.Fatal(err)
}
// OnChange 落盘。
if _, err := os.Stat(filepath.Join(dir, "roles.d", "researcher.json")); err != nil {
t.Fatalf("role file missing: %v", err)
}
// 新注册表重载:角色可恢复。
reg2 := squad.NewRoleRegistry()
st2, _ := New(dir)
if err := st2.LoadRoles(reg2); err != nil {
t.Fatal(err)
}
if spec, ok := reg2.Get("researcher"); !ok || spec.Backend != "stub" {
t.Fatalf("role not reloaded: %+v ok=%v", spec, ok)
}
}
func TestBlueprintsPersistAcrossReload(t *testing.T) {
dir := t.TempDir()
st, _ := New(dir)
bps1 := squad.NewBlueprintRegistry()
if err := st.LoadBlueprints(bps1); err != nil {
t.Fatal(err)
}
if err := bps1.Register(squad.Blueprint{
Key: "report", Title: "报告",
Steps: []squad.StepSpec{{ID: "s1", Role: "writer", Title: "成文"}},
}); err != nil {
t.Fatal(err)
}
st2, _ := New(dir)
bps2 := squad.NewBlueprintRegistry()
if err := st2.LoadBlueprints(bps2); err != nil {
t.Fatal(err)
}
if bp, ok := bps2.Get("report"); !ok || len(bp.Steps) != 1 {
t.Fatalf("blueprint not reloaded: %+v ok=%v", bp, ok)
}
}
func TestLoadRolesRejectsInvalidFile(t *testing.T) {
dir := t.TempDir()
st, _ := New(dir)
if err := os.WriteFile(filepath.Join(dir, "roles.d", "bad.json"), []byte(`{"role":""}`), 0o644); err != nil {
t.Fatal(err)
}
reg := squad.NewRoleRegistry()
if err := st.LoadRoles(reg); err == nil {
t.Fatal("invalid role file must fail load")
}
}