Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,22 @@ No Docker daemon is on the session path.

| Step | What runs |
| --- | --- |
| `make image` | Docker, to build the guest disk |
| `make image` | Docker, to pack the golden disk |
| `abox` / `--probe-vm` | `abox` + `abox-vmm` + libkrun VM |

`~/.abox/images/abox-guest.raw` is the **base guest disk**
(Alpine + git + `abox-guest`). Think of it like the microVMs golden hard-drive image. Sessions do not boot that file read/write.
On start, ABox clones it (APFS copy-on-write when available) to:
Guest storage is files on the Mac, not a mount of your repo. Three files matter:

`~/.abox/sessions/<session-id>/root.raw`
| File | Role | Attached to the running VM? |
| --- | --- | --- |
| `~/.abox/images/abox-guest.raw` | **Golden image.** Alpine + git + patch + `abox-guest`. Clean template from `make image`. | No. Never opened read/write by a session. |
| `~/.abox/sessions/<session-id>/root.raw` | **Session disk.** Clone of the golden image (APFS copy-on-write when available, else a full copy). This is the writable microVM hard drive (`/dev/vda`). Repo snapshot, `/work/repo`, guest Git, and anything the agent writes land here. | Yes. |
| `~/.abox/sessions/<session-id>/config.raw` | **Sealed config.** ~1 MiB, mode `0400`, read-only. Session id, capability, model, API keys. Guest reads it as `/dev/vdb`. Not an OS image and not cloned from the golden disk. | Yes (read-only). |

The microVM attaches that session disk. Repo snapshot and agent work land
there. The cache image stays the clean template for the next session.
`~/.abox/sessions/<session-id>/root.raw` is a copy of the golden image/template to use within a running instance of ABox so a user can write to it, prompt, etc... The guest boots that file as its writable disk. Prompts, tools, /work/repo, patches, run_command all land there. The golden abox-guest.raw stays a clean template.

Each session also gets a small read-only `config.raw` (session id,
capability, model, keys). That is not the OS disk.
The VM boots **only** the session clone, not the golden file. Destroy a session directory and that run’s guest files are gone; the golden image stays clean for the next `abox`. `make image-update` patches `/usr/local/bin/abox-guest` on an existing golden disk; `make image` rebuilds the golden disk from scratch.

`abox --resume` does **not** clone the golden image again. It boots the existing `root.raw` for that session and the guest reloads conversation state from `/var/lib/abox/context.json` on that disk. The host git tree is not re-copied (that would overwrite guest work).

## Quickstart

Expand All @@ -90,6 +92,7 @@ abox

- `/provider` sets Grok, OpenAI, or Anthropic API keys
- `/mcp` lists configured Streamable HTTP MCP servers and accepts a Bearer token (`abox mcp login` for OAuth)
- `abox --resume` reopens the latest session for this repo (same `root.raw` and LLM conversation). `abox --resume <id>` picks a session. Plain `abox` still starts a new session.
- `ctrl+c` quits
- The agent runs only inside the guest (MicroVM)

Expand Down
9 changes: 8 additions & 1 deletion cmd/abox-guest/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ func run() error {
fmt.Fprintf(os.Stderr, "abox-guest: mcp: %v\n", err)
}
defer mcpMgr.Close()
loop := &agent.Loop{Model: agent.ModelFromGuest(cfg.Model), Repo: repo, MCP: mcpMgr}
loop := &agent.Loop{Model: agent.ModelFromGuest(cfg.Model), Repo: repo, MCP: mcpMgr, ContextFile: agent.DefaultContextFile}
if err := loop.LoadContext(); err != nil {
fmt.Fprintf(os.Stderr, "abox-guest: context: %v\n", err)
}
conn, err := dialVsock(cfg.VsockPort)
if err != nil {
return fmt.Errorf("vsock: %w", err)
Expand Down Expand Up @@ -113,8 +116,10 @@ func handleTurn(conn net.Conn, loop *agent.Loop, req protocol.Frame) error {
_ = protocol.WriteFrame(conn, protocol.Frame{ID: req.ID, Method: "agent_event", Params: raw})
}
if err := loop.Turn(context.Background(), p.Text); err != nil {
_ = loop.SaveContext()
return protocol.WriteFrame(conn, protocol.Frame{ID: req.ID, Error: &protocol.Error{Code: "agent", Message: err.Error()}})
}
_ = loop.SaveContext()
ok, _ := protocol.EncodeParams(map[string]bool{"ok": true})
return protocol.WriteFrame(conn, protocol.Frame{ID: req.ID, Result: ok})
}
Expand Down Expand Up @@ -251,6 +256,7 @@ func handle(loop *agent.Loop, repo tools.Repo, mcpMgr *guestmcp.Manager, archive
_ = execSetTime(t)
out.Result, _ = protocol.EncodeParams(map[string]bool{"ok": true})
case "shutdown":
_ = loop.SaveContext()
out.Result, _ = protocol.EncodeParams(map[string]bool{"ok": true})
default:
err = fmt.Errorf("unknown method %q", req.Method)
Expand Down Expand Up @@ -321,6 +327,7 @@ func prepMounts() {
_ = os.MkdirAll("/dev", 0o755)
_ = os.MkdirAll("/tmp", 0o1777)
_ = os.MkdirAll("/work/repo", 0o755)
_ = os.MkdirAll("/var/lib/abox", 0o755)
_ = mountIfNeeded("proc", "/proc")
_ = mountIfNeeded("sysfs", "/sys")
_ = mountIfNeeded("devtmpfs", "/dev")
Expand Down
80 changes: 59 additions & 21 deletions cmd/abox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func run() error {
prompt := fs.String("prompt", "", "prompt for exec mode")
modelName := fs.String("model", "", "configured model profile name")
probeVM := fs.Bool("probe-vm", false, "boot the guest and list files; no model call")
resume := fs.Bool("resume", false, "resume a previous session for this repository (same root.raw and conversation)")
args := os.Args[1:]
execMode := false
if len(args) > 0 && args[0] == "exec" {
Expand Down Expand Up @@ -70,21 +71,41 @@ func run() error {
return err
}

sess, err := session.Create(wd, "pending")
if err != nil {
return err
}
snap, err := repository.OpenForSession(wd, filepath.Join(sess.Dir, "host-tree"))
if err != nil {
return err
}
sess.RepoRoot = snap.Root
sess.HEAD = snap.HEAD
if err := sess.WriteMeta(); err != nil {
return err
resumeID := ""
if *resume {
if extra := fs.Args(); len(extra) > 0 {
resumeID = extra[0]
}
}
if snap.Ephemeral {
fmt.Fprintf(os.Stderr, "abox: no clean committed worktree; using an ephemeral snapshot. host git is unchanged.\n")

var sess *session.Session
var snap repository.Snapshot
if *resume {
loaded, err := loadResumeSession(wd, resumeID)
if err != nil {
return err
}
sess = loaded
fmt.Fprintf(os.Stderr, "abox: resuming session %s\n", sess.ID)
} else {
created, err := session.Create(wd, "pending")
if err != nil {
return err
}
sess = created
opened, err := repository.OpenForSession(wd, filepath.Join(sess.Dir, "host-tree"))
if err != nil {
return err
}
snap = opened
sess.RepoRoot = snap.Root
sess.HEAD = snap.HEAD
if err := sess.WriteMeta(); err != nil {
return err
}
if snap.Ephemeral {
fmt.Fprintf(os.Stderr, "abox: no clean committed worktree; using an ephemeral snapshot. host git is unchanged.\n")
}
}

var sb *runtime.Sandbox
Expand All @@ -97,7 +118,7 @@ func run() error {
if err != nil {
return err
}
if err := runtime.Prepare(sess, image, sel, currentSecrets(cfg), mcpServers); err != nil {
if err := runtime.Prepare(sess, image, sel, currentSecrets(cfg), mcpServers, *resume); err != nil {
if execMode {
return err
}
Expand All @@ -124,12 +145,14 @@ func run() error {
sb = started
vmState = "ready"
defer sb.Stop()
archive, err := repository.ArchiveHEAD(snap.Root)
if err != nil {
return err
}
if err := sb.TransferArchive(context.Background(), archive); err != nil {
fmt.Fprintf(os.Stderr, "abox: repo transfer: %v\n", err)
if !*resume {
archive, err := repository.ArchiveHEAD(snap.Root)
if err != nil {
return err
}
if err := sb.TransferArchive(context.Background(), archive); err != nil {
fmt.Fprintf(os.Stderr, "abox: repo transfer: %v\n", err)
}
}
}
}
Expand Down Expand Up @@ -169,6 +192,21 @@ func runExec(sb *runtime.Sandbox, prompt string) error {
})
}

func loadResumeSession(wd, id string) (*session.Session, error) {
if id != "" {
return session.Load(id)
}
abs, err := filepath.Abs(wd)
if err != nil {
abs = wd
}
roots := []string{abs}
if top, err := repository.TopLevel(wd); err == nil {
roots = append(roots, top)
}
return session.LatestForRepo(roots...)
}

func currentSecrets(cfg config.File) map[string]string {
out := map[string]string{}
for _, name := range []string{"XAI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"} {
Expand Down
99 changes: 94 additions & 5 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"

Expand All @@ -19,12 +21,18 @@ type MCPClient interface {
Call(ctx context.Context, server, tool string, args json.RawMessage) (string, error)
}

const (
DefaultContextFile = "/var/lib/abox/context.json"
maxContextBytes = 2 << 20
)

type Loop struct {
Model config.Model
Repo tools.Repo
MCP MCPClient
Messages []provider.Message
OnEvent func(protocol.AgentEvent)
Model config.Model
Repo tools.Repo
MCP MCPClient
Messages []provider.Message
ContextFile string
OnEvent func(protocol.AgentEvent)
}

func BuiltinTools() []provider.ToolSchema {
Expand Down Expand Up @@ -67,6 +75,7 @@ func (l *Loop) Turn(ctx context.Context, user string) error {
events, err := provider.Stream(ctx, l.Model, l.Messages, l.allTools())
if err != nil {
l.emit(protocol.AgentEvent{Kind: "error", Err: err.Error()})
_ = l.SaveContext()
return err
}
var text string
Expand All @@ -80,6 +89,7 @@ func (l *Loop) Turn(ctx context.Context, user string) error {
tool = ev
case "error":
l.emit(protocol.AgentEvent{Kind: "error", Err: ev.Err.Error()})
_ = l.SaveContext()
return ev.Err
}
}
Expand All @@ -88,6 +98,7 @@ func (l *Loop) Turn(ctx context.Context, user string) error {
l.Messages = append(l.Messages, provider.Message{Role: "assistant", Content: text})
}
l.emit(protocol.AgentEvent{Kind: "done"})
_ = l.SaveContext()
return nil
}
l.Messages = append(l.Messages, provider.Message{
Expand All @@ -109,9 +120,87 @@ func (l *Loop) Turn(ctx context.Context, user string) error {
ToolResult: result,
})
}
_ = l.SaveContext()
return fmt.Errorf("turn limit reached")
}

func (l *Loop) contextPath() string {
if l.ContextFile != "" {
return l.ContextFile
}
return DefaultContextFile
}

func (l *Loop) SaveContext() error {
return SaveMessages(l.contextPath(), l.Messages)
}

func (l *Loop) LoadContext() error {
msgs, err := LoadMessages(l.contextPath())
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
l.Messages = msgs
return nil
}

func SaveMessages(path string, msgs []provider.Message) error {
if path == "" {
return fmt.Errorf("empty context path")
}
trimmed := trimMessages(msgs, maxContextBytes)
data, err := json.MarshalIndent(trimmed, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}

func LoadMessages(path string) ([]provider.Message, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var msgs []provider.Message
if err := json.Unmarshal(data, &msgs); err != nil {
return nil, err
}
return msgs, nil
}

func trimMessages(msgs []provider.Message, max int) []provider.Message {
if max <= 0 {
return nil
}
var out []provider.Message
var size int
for i := len(msgs) - 1; i >= 0; i-- {
b, err := json.Marshal(msgs[i])
if err != nil {
continue
}
if size+len(b) > max && len(out) > 0 {
break
}
out = append(out, msgs[i])
size += len(b)
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}

func (l *Loop) allTools() []provider.ToolSchema {
tools := BuiltinTools()
if l.MCP == nil {
Expand Down
46 changes: 46 additions & 0 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"

Expand All @@ -25,6 +26,51 @@ func TestTurnRequiresGuestRepo(t *testing.T) {
}
}

func TestSaveLoadMessagesRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "context.json")
l := &Loop{
ContextFile: path,
Messages: []provider.Message{
{Role: "user", Content: "hi"},
{Role: "assistant", Content: "hello"},
{Role: "tool", ToolID: "1", ToolResult: "ok"},
},
}
if err := l.SaveContext(); err != nil {
t.Fatal(err)
}
l2 := &Loop{ContextFile: path}
if err := l2.LoadContext(); err != nil {
t.Fatal(err)
}
if len(l2.Messages) != 3 || l2.Messages[0].Content != "hi" || l2.Messages[2].ToolResult != "ok" {
t.Fatalf("%#v", l2.Messages)
}
}

func TestLoadContextMissingFile(t *testing.T) {
l := &Loop{ContextFile: filepath.Join(t.TempDir(), "missing.json")}
if err := l.LoadContext(); err != nil {
t.Fatal(err)
}
if l.Messages != nil {
t.Fatalf("%#v", l.Messages)
}
}

func TestTrimMessagesKeepsNewest(t *testing.T) {
msgs := []provider.Message{
{Role: "user", Content: strings.Repeat("a", 200)},
{Role: "user", Content: strings.Repeat("b", 200)},
{Role: "user", Content: "tail"},
}
got := trimMessages(msgs, 80)
if len(got) == 0 || got[len(got)-1].Content != "tail" {
t.Fatalf("%#v", got)
}
}

func TestExecUnknownTool(t *testing.T) {
l := &Loop{}
_, err := l.execTool(context.Background(), provider.Event{ToolName: "host_shell"})
Expand Down
Loading
Loading