Files
codegirl-games/agentbox/internal/trace/store.go
T

252 lines
6.3 KiB
Go

package trace
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"agentbox/internal/environment"
)
const SchemaVersion = "1"
type Run struct {
SchemaVersion string `json:"schema_version"`
ID string `json:"id"`
Task string `json:"task"`
Application string `json:"application"`
Agent string `json:"agent"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
StepCount int `json:"step_count"`
Error string `json:"error,omitempty"`
}
type ObservationRecord struct {
Screenshot string `json:"screenshot"`
Logs []environment.LogEntry `json:"logs,omitempty"`
PreviousActions []environment.InputAction `json:"previous_actions,omitempty"`
}
type AgentRecord struct {
Message string `json:"message"`
}
type StepRecord struct {
Step int `json:"step"`
Timestamp time.Time `json:"timestamp"`
Observation ObservationRecord `json:"observation"`
Agent AgentRecord `json:"agent"`
Action *environment.InputAction `json:"action,omitempty"`
Done bool `json:"done,omitempty"`
}
type Store struct {
root string
runDir string
run Run
steps *os.File
actions *os.File
}
func New(root, task, application, agentName string) (*Store, error) {
id, err := newID()
if err != nil {
return nil, err
}
runDir := filepath.Join(root, ".agentbox", "runs", id)
if err := os.MkdirAll(filepath.Join(runDir, "screenshots"), 0o755); err != nil {
return nil, fmt.Errorf("create run directory: %w", err)
}
steps, err := os.Create(filepath.Join(runDir, "steps.jsonl"))
if err != nil {
return nil, fmt.Errorf("create step trace: %w", err)
}
actions, err := os.Create(filepath.Join(runDir, "actions.jsonl"))
if err != nil {
_ = steps.Close()
return nil, fmt.Errorf("create action trace: %w", err)
}
store := &Store{
root: root, runDir: runDir, steps: steps, actions: actions,
run: Run{
SchemaVersion: SchemaVersion,
ID: id,
Task: task,
Application: application,
Agent: agentName,
Status: "running",
StartedAt: time.Now().UTC(),
},
}
if err := store.writeRun(); err != nil {
_ = steps.Close()
_ = actions.Close()
return nil, err
}
return store, nil
}
func (s *Store) ID() string {
return s.run.ID
}
func (s *Store) Directory() string {
return s.runDir
}
func (s *Store) SaveScreenshot(step int, data []byte) (string, error) {
relative := filepath.Join("screenshots", fmt.Sprintf("%04d.png", step))
if err := os.WriteFile(filepath.Join(s.runDir, relative), data, 0o644); err != nil {
return "", fmt.Errorf("write screenshot: %w", err)
}
return filepath.ToSlash(relative), nil
}
func (s *Store) Record(record StepRecord) error {
if err := appendJSON(s.steps, record); err != nil {
return fmt.Errorf("record step: %w", err)
}
if record.Action != nil {
action := struct {
Step int `json:"step"`
Timestamp time.Time `json:"timestamp"`
Action environment.InputAction `json:"action"`
}{record.Step, record.Timestamp, *record.Action}
if err := appendJSON(s.actions, action); err != nil {
return fmt.Errorf("record action: %w", err)
}
}
s.run.StepCount = record.Step
return s.writeRun()
}
func (s *Store) WriteLogs(logs []environment.LogEntry) error {
var stdout, stderr string
for _, entry := range logs {
if entry.Stream == "stderr" {
stderr = entry.Message
} else if entry.Stream == "stdout" {
stdout = entry.Message
}
}
if err := os.WriteFile(filepath.Join(s.runDir, "stdout.log"), []byte(stdout), 0o644); err != nil {
return err
}
return os.WriteFile(filepath.Join(s.runDir, "stderr.log"), []byte(stderr), 0o644)
}
func (s *Store) Finish(runErr error) error {
if s.steps != nil {
_ = s.steps.Close()
s.steps = nil
}
if s.actions != nil {
_ = s.actions.Close()
s.actions = nil
}
s.run.FinishedAt = time.Now().UTC()
if runErr != nil {
s.run.Status = "failed"
s.run.Error = runErr.Error()
} else {
s.run.Status = "complete"
}
return s.writeRun()
}
func List(root string) ([]Run, error) {
directories, err := os.ReadDir(filepath.Join(root, ".agentbox", "runs"))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var runs []Run
for _, directory := range directories {
if !directory.IsDir() {
continue
}
run, err := Read(root, directory.Name())
if err == nil && run.SchemaVersion == SchemaVersion {
runs = append(runs, run)
}
}
sort.Slice(runs, func(i, j int) bool {
return runs[i].StartedAt.After(runs[j].StartedAt)
})
return runs, nil
}
func Read(root, id string) (Run, error) {
if filepath.Base(id) != id {
return Run{}, errors.New("invalid run ID")
}
data, err := os.ReadFile(filepath.Join(root, ".agentbox", "runs", id, "run.json"))
if err != nil {
return Run{}, err
}
var run Run
if err := json.Unmarshal(data, &run); err != nil {
return Run{}, err
}
return run, nil
}
func ReadSteps(root, id string) ([]StepRecord, error) {
if filepath.Base(id) != id {
return nil, errors.New("invalid run ID")
}
file, err := os.Open(filepath.Join(root, ".agentbox", "runs", id, "steps.jsonl"))
if err != nil {
return nil, err
}
defer file.Close()
var steps []StepRecord
scanner := bufio.NewScanner(file)
for scanner.Scan() {
var step StepRecord
if err := json.Unmarshal(scanner.Bytes(), &step); err != nil {
return nil, err
}
steps = append(steps, step)
}
return steps, scanner.Err()
}
func appendJSON(file *os.File, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
if _, err := file.Write(append(data, '\n')); err != nil {
return err
}
return file.Sync()
}
func (s *Store) writeRun() error {
data, err := json.MarshalIndent(s.run, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(s.runDir, "run.json"), append(data, '\n'), 0o644)
}
func newID() (string, error) {
random := make([]byte, 3)
if _, err := rand.Read(random); err != nil {
return "", err
}
return time.Now().UTC().Format("20060102T150405") + "-" + hex.EncodeToString(random), nil
}