Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e702497996 | ||
|
|
4d2fb4a733 | ||
|
|
f2fee5d26b | ||
|
|
c4f8dbeaaf | ||
|
|
3037dbc2dd | ||
|
|
e9197798f9 | ||
|
|
94146fcae2 | ||
|
|
54dc5658c6 | ||
|
|
ad5a4b4114 | ||
|
|
96dade334d |
@@ -0,0 +1,3 @@
|
|||||||
|
.agentbox/
|
||||||
|
/agentbox
|
||||||
|
examples/mover/mover
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.PHONY: build demo demo-binary phase1 test
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o agentbox ./cmd/agentbox
|
||||||
|
|
||||||
|
demo-binary:
|
||||||
|
./scripts/build_demo.sh
|
||||||
|
|
||||||
|
demo: build demo-binary
|
||||||
|
./agentbox run ./examples/mover --task "Launch the application, move the character to the right, and describe what happened."
|
||||||
|
|
||||||
|
phase1:
|
||||||
|
go run ./cmd/agentbox phase1
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Agentbox
|
||||||
|
|
||||||
|
This is a local technical spike for a programmable graphical Linux
|
||||||
|
environment. It proves that a controller can launch an ordinary GUI process
|
||||||
|
headlessly, observe pixels and logs, inject keyboard and mouse input, drive the
|
||||||
|
application through a replaceable agent, record a trace, and shut the
|
||||||
|
environment down.
|
||||||
|
|
||||||
|
The default agent is deterministic and requires no API credentials. One
|
||||||
|
optional OpenAI Responses adapter demonstrates screenshot + text model control.
|
||||||
|
This is still a local spike, not a cloud control plane or production sandbox.
|
||||||
|
See [the architecture decision](docs/architecture.md) for the choices and
|
||||||
|
security boundary.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux
|
||||||
|
- Go 1.22 or newer
|
||||||
|
- Docker Engine with a running daemon
|
||||||
|
- permission to use Docker without an interactive `sudo` prompt
|
||||||
|
|
||||||
|
No X server, window manager, or C compiler is required on the host; those
|
||||||
|
dependencies are built into the container image.
|
||||||
|
|
||||||
|
## One-command demo
|
||||||
|
|
||||||
|
From this directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make demo
|
||||||
|
```
|
||||||
|
|
||||||
|
That one command:
|
||||||
|
|
||||||
|
1. builds the `agentbox` CLI and mover executable;
|
||||||
|
2. creates a restricted Xvfb/Openbox environment;
|
||||||
|
3. stages and launches the executable;
|
||||||
|
4. runs the deterministic observe/press/wait/release/observe loop;
|
||||||
|
5. records screenshots, actions, decisions, logs, and final status;
|
||||||
|
6. stops and removes the environment, including on failure or interruption.
|
||||||
|
|
||||||
|
The equivalent explicit commands are:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make build demo-binary
|
||||||
|
./agentbox run ./examples/mover \
|
||||||
|
--task "Launch the application, move the character to the right, and describe what happened."
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags can appear before or after the path.
|
||||||
|
|
||||||
|
## Run a private executable
|
||||||
|
|
||||||
|
Pass a prebuilt Linux executable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./agentbox run ./path/to/application --task "Open the menu and click Settings."
|
||||||
|
```
|
||||||
|
|
||||||
|
For a directory, add `agentbox.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "game",
|
||||||
|
"args": ["--windowed"],
|
||||||
|
"env": {"EXAMPLE": "value"},
|
||||||
|
"window_title": "My Game"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The command is resolved relative to the manifest. The current runtime is
|
||||||
|
Debian-based, so dynamically linked executables must have compatible libraries.
|
||||||
|
|
||||||
|
## Model agent
|
||||||
|
|
||||||
|
The model adapter is opt-in:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export OPENAI_API_KEY="..."
|
||||||
|
./agentbox run ./path/to/application \
|
||||||
|
--task "Move the character right and report the result." \
|
||||||
|
--agent openai \
|
||||||
|
--model gpt-5
|
||||||
|
```
|
||||||
|
|
||||||
|
Each request contains the task, current screenshot, previous actions, step
|
||||||
|
history, and recent application logs. Strict structured output allows one
|
||||||
|
backend-neutral input action or completion. Use `--max-steps` to bound a run.
|
||||||
|
The environment itself still has no network access.
|
||||||
|
|
||||||
|
## Runs and inspection
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./agentbox runs
|
||||||
|
./agentbox inspect <run-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Each run writes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.agentbox/runs/<run-id>/
|
||||||
|
├── run.json
|
||||||
|
├── actions.jsonl
|
||||||
|
├── steps.jsonl
|
||||||
|
├── screenshots/
|
||||||
|
│ ├── 0001.png
|
||||||
|
│ └── ...
|
||||||
|
├── stdout.log
|
||||||
|
└── stderr.log
|
||||||
|
```
|
||||||
|
|
||||||
|
`run.json` has `schema_version: "1"` plus task, application, agent, timestamps,
|
||||||
|
status, and step count. `steps.jsonl` records each observation reference, agent
|
||||||
|
message, and action. `actions.jsonl` is a compact action-only stream.
|
||||||
|
|
||||||
|
## Original environment proof
|
||||||
|
|
||||||
|
The visual centroid test from Phase 1 remains available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make phase1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development checks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make test
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"agentbox/internal/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listRuns() error {
|
||||||
|
projectRoot, err := findProjectRoot()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runs, err := trace.List(projectRoot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(runs) == 0 {
|
||||||
|
fmt.Println("No recorded runs.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, run := range runs {
|
||||||
|
fmt.Printf("%s %-8s %-20s %s\n",
|
||||||
|
run.ID, run.Status, run.Agent, run.Task)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspect(args []string) error {
|
||||||
|
if len(args) != 1 {
|
||||||
|
return errors.New("inspect requires one run ID")
|
||||||
|
}
|
||||||
|
projectRoot, err := findProjectRoot()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runID := args[0]
|
||||||
|
run, err := trace.Read(projectRoot, runID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
steps, err := trace.ReadSteps(projectRoot, runID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
output := struct {
|
||||||
|
Run trace.Run `json:"run"`
|
||||||
|
Steps []trace.StepRecord `json:"steps"`
|
||||||
|
Directory string `json:"directory"`
|
||||||
|
}{
|
||||||
|
Run: run,
|
||||||
|
Steps: steps,
|
||||||
|
Directory: filepath.Join(projectRoot, ".agentbox", "runs", run.ID),
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(output, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"agentbox/internal/phase1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "phase1":
|
||||||
|
err = phase1.Run(ctx)
|
||||||
|
case "run":
|
||||||
|
err = run(ctx, os.Args[2:])
|
||||||
|
case "runs":
|
||||||
|
err = listRuns()
|
||||||
|
case "inspect":
|
||||||
|
err = inspect(os.Args[2:])
|
||||||
|
default:
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "agentbox: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprintln(os.Stderr, `usage:
|
||||||
|
agentbox run <path> --task "<task>" [--agent deterministic|openai] [--model <model>]
|
||||||
|
agentbox runs
|
||||||
|
agentbox inspect <run-id>
|
||||||
|
agentbox phase1`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseRunOptionsAllowsFlagsAfterPath(t *testing.T) {
|
||||||
|
options, err := parseRunOptions([]string{
|
||||||
|
"./game", "--task", "move right", "--max-steps=7",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if options.applicationPath != "./game" ||
|
||||||
|
options.task != "move right" ||
|
||||||
|
options.maxSteps != 7 {
|
||||||
|
t.Fatalf("options = %#v", options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findProjectRoot lets commands work from any directory inside the Agentbox
|
||||||
|
// module. The module declaration is a stronger marker than a directory name.
|
||||||
|
func findProjectRoot() (string, error) {
|
||||||
|
currentDirectory, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
goModule, readErr := os.ReadFile(filepath.Join(currentDirectory, "go.mod"))
|
||||||
|
if readErr == nil && bytes.Contains(goModule, []byte("module agentbox")) {
|
||||||
|
return currentDirectory, nil
|
||||||
|
}
|
||||||
|
parentDirectory := filepath.Dir(currentDirectory)
|
||||||
|
if parentDirectory == currentDirectory {
|
||||||
|
return "", errors.New("run agentbox from inside its module directory")
|
||||||
|
}
|
||||||
|
currentDirectory = parentDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"agentbox/internal/agent"
|
||||||
|
"agentbox/internal/appspec"
|
||||||
|
"agentbox/internal/environment/dockerx11"
|
||||||
|
agentRuntime "agentbox/internal/runtime"
|
||||||
|
"agentbox/internal/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runOptions struct {
|
||||||
|
applicationPath string
|
||||||
|
task string
|
||||||
|
agentName string
|
||||||
|
modelName string
|
||||||
|
maxSteps int
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, args []string) error {
|
||||||
|
options, err := parseRunOptions(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
projectRoot, err := findProjectRoot()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applicationCommand, err := appspec.Resolve(options.applicationPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
controller, err := newAgent(options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
traceStore, err := trace.New(
|
||||||
|
projectRoot,
|
||||||
|
options.task,
|
||||||
|
options.applicationPath,
|
||||||
|
controller.Name(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
desktopEnvironment := dockerx11.New(dockerx11.Config{
|
||||||
|
ProjectRoot: projectRoot,
|
||||||
|
RunID: traceStore.ID(),
|
||||||
|
Output: os.Stdout,
|
||||||
|
})
|
||||||
|
err = agentRuntime.Run(ctx, agentRuntime.Config{
|
||||||
|
Task: options.task,
|
||||||
|
Command: applicationCommand,
|
||||||
|
Controller: controller,
|
||||||
|
Environment: desktopEnvironment,
|
||||||
|
TraceStore: traceStore,
|
||||||
|
MaxSteps: options.maxSteps,
|
||||||
|
Output: os.Stdout,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Run artifacts: %s\n", traceStore.Directory())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Run artifacts written to:\n%s\n", traceStore.Directory())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRunOptions accepts flags before or after the application path so the CLI
|
||||||
|
// matches the natural "agentbox run ./app --task ..." form shown in the docs.
|
||||||
|
func parseRunOptions(args []string) (runOptions, error) {
|
||||||
|
options := runOptions{
|
||||||
|
agentName: "deterministic",
|
||||||
|
modelName: "gpt-5",
|
||||||
|
maxSteps: 20,
|
||||||
|
}
|
||||||
|
for index := 0; index < len(args); index++ {
|
||||||
|
argument := args[index]
|
||||||
|
if !strings.HasPrefix(argument, "--") {
|
||||||
|
if options.applicationPath != "" {
|
||||||
|
return options, errors.New("run accepts exactly one application path")
|
||||||
|
}
|
||||||
|
options.applicationPath = argument
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
flagName, flagValue, _ := strings.Cut(strings.TrimPrefix(argument, "--"), "=")
|
||||||
|
if flagValue == "" {
|
||||||
|
index++
|
||||||
|
if index >= len(args) {
|
||||||
|
return options, fmt.Errorf("--%s requires a value", flagName)
|
||||||
|
}
|
||||||
|
flagValue = args[index]
|
||||||
|
}
|
||||||
|
switch flagName {
|
||||||
|
case "task":
|
||||||
|
options.task = flagValue
|
||||||
|
case "agent":
|
||||||
|
options.agentName = flagValue
|
||||||
|
case "model":
|
||||||
|
options.modelName = flagValue
|
||||||
|
case "max-steps":
|
||||||
|
maxSteps, err := strconv.Atoi(flagValue)
|
||||||
|
if err != nil || maxSteps < 1 {
|
||||||
|
return options, errors.New("--max-steps must be a positive integer")
|
||||||
|
}
|
||||||
|
options.maxSteps = maxSteps
|
||||||
|
default:
|
||||||
|
return options, fmt.Errorf("unknown flag --%s", flagName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if options.applicationPath == "" {
|
||||||
|
return options, errors.New("run requires an application path")
|
||||||
|
}
|
||||||
|
if options.task == "" {
|
||||||
|
return options, errors.New("run requires --task")
|
||||||
|
}
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgent(options runOptions) (agent.Agent, error) {
|
||||||
|
switch options.agentName {
|
||||||
|
case "deterministic":
|
||||||
|
return &agent.Deterministic{}, nil
|
||||||
|
case "openai":
|
||||||
|
return agent.NewOpenAI(agent.OpenAIConfig{
|
||||||
|
APIKey: os.Getenv("OPENAI_API_KEY"),
|
||||||
|
Model: options.modelName,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"unknown agent %q (want deterministic or openai)",
|
||||||
|
options.agentName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# How Agentbox works
|
||||||
|
|
||||||
|
This document explains the prototype in simple terms. The exact technical names
|
||||||
|
are included in parentheses for readers who want to dig deeper.
|
||||||
|
|
||||||
|
## The big idea
|
||||||
|
|
||||||
|
Imagine giving a robot its own computer in a locked room.
|
||||||
|
|
||||||
|
The robot cannot look inside the program or use secret game controls. It can
|
||||||
|
only:
|
||||||
|
|
||||||
|
- look at the screen;
|
||||||
|
- press and release keyboard keys;
|
||||||
|
- move and click the mouse;
|
||||||
|
- read messages printed by the program.
|
||||||
|
|
||||||
|
Agentbox builds that room, starts the program, lets the robot interact with it,
|
||||||
|
and records everything that happened.
|
||||||
|
|
||||||
|
## The main parts
|
||||||
|
|
||||||
|
Each part has one job:
|
||||||
|
|
||||||
|
```text
|
||||||
|
You type a command
|
||||||
|
|
|
||||||
|
v
|
||||||
|
CLI: understands what you asked for
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Runtime: runs the experiment step by step
|
||||||
|
/ \
|
||||||
|
v v
|
||||||
|
Environment Agent
|
||||||
|
"the computer" "the robot"
|
||||||
|
| |
|
||||||
|
+------v-------+
|
||||||
|
|
|
||||||
|
Trace
|
||||||
|
"the experiment notebook"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **CLI:** The `agentbox` command you run in a terminal.
|
||||||
|
- **Runtime:** The referee. It asks the environment for a screenshot, gives it
|
||||||
|
to the agent, carries out the agent's next action, and repeats.
|
||||||
|
- **Environment:** The temporary Linux computer containing the application.
|
||||||
|
- **Application:** The game or other interactive program being tested.
|
||||||
|
- **Agent:** The decision-maker. It can be a fixed test script or an AI model.
|
||||||
|
- **Trace:** The saved screenshots, actions, logs, and final result.
|
||||||
|
|
||||||
|
The important rule is that these parts do not cheat by reaching into each
|
||||||
|
other. The agent does not know about Docker or X11. The environment does not
|
||||||
|
know whether the agent is OpenAI, another model, or a fixed script.
|
||||||
|
|
||||||
|
## What happens during one run
|
||||||
|
|
||||||
|
When you run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
agentbox run ./my-game --task "Move the character right."
|
||||||
|
```
|
||||||
|
|
||||||
|
Agentbox does this:
|
||||||
|
|
||||||
|
1. Creates a fresh temporary room.
|
||||||
|
2. Copies the executable into that room.
|
||||||
|
3. Starts a pretend monitor inside the room.
|
||||||
|
4. Launches the application on that monitor.
|
||||||
|
5. Takes a screenshot.
|
||||||
|
6. Gives the screenshot, task, recent logs, and earlier actions to the agent.
|
||||||
|
7. Receives one action, such as `key_down RIGHT`.
|
||||||
|
8. Sends that action to the temporary computer.
|
||||||
|
9. Repeats until the agent says it is finished or reaches the step limit.
|
||||||
|
10. Saves the experiment and destroys the temporary room.
|
||||||
|
|
||||||
|
Stopping on errors is important. Even if the application or agent fails,
|
||||||
|
Agentbox still tries to save the logs and remove the environment.
|
||||||
|
|
||||||
|
## How the pretend computer works
|
||||||
|
|
||||||
|
The temporary room is a **Docker container**. A container is like a lightweight
|
||||||
|
box around a group of programs. It keeps files and processes separate enough
|
||||||
|
for this experiment, but it is not strong enough to safely hold a determined
|
||||||
|
attacker. The security section explains that limitation.
|
||||||
|
|
||||||
|
There is no physical monitor in the container, so we use a pretend one:
|
||||||
|
|
||||||
|
- **Xvfb** is a screen that exists only in memory. The application thinks it is
|
||||||
|
drawing to a normal 640×480 monitor.
|
||||||
|
- **Openbox** acts like a tiny desktop. It puts windows in the right place and
|
||||||
|
decides which window receives keyboard input.
|
||||||
|
- **scrot** takes a picture of the pretend monitor and saves it as a PNG.
|
||||||
|
- **xdotool** sends ordinary-looking keyboard and mouse events.
|
||||||
|
|
||||||
|
The application is not modified to accept special Agentbox commands. From its
|
||||||
|
point of view, a person pressed a key or clicked the mouse.
|
||||||
|
|
||||||
|
## Why use these old-looking tools?
|
||||||
|
|
||||||
|
### Why X11 instead of Wayland?
|
||||||
|
|
||||||
|
Linux has two common ways to manage graphical windows: X11 and Wayland.
|
||||||
|
|
||||||
|
Wayland is newer and safer for everyday desktops. One of its safety features is
|
||||||
|
that programs cannot freely spy on the whole screen or pretend to be the
|
||||||
|
keyboard. Those are exactly the powers Agentbox needs, so a headless Wayland
|
||||||
|
version would need more complicated, compositor-specific plumbing.
|
||||||
|
|
||||||
|
X11 already has small, well-understood tools for this experiment. Because all
|
||||||
|
X11 details are hidden behind the `Environment` interface, we can replace this
|
||||||
|
backend later without changing the agent.
|
||||||
|
|
||||||
|
One X11 screen is used per environment. Programs sharing an X11 screen can
|
||||||
|
interfere with each other, so unrelated runs must never share one.
|
||||||
|
|
||||||
|
### Why no VNC or browser viewer?
|
||||||
|
|
||||||
|
VNC would let a human watch the desktop live. That sounds useful, but the agent
|
||||||
|
only needs screenshots and input for now. Adding VNC would mean more servers,
|
||||||
|
network ports, and video encoding without proving anything new. It can be added
|
||||||
|
later as a viewer without changing how the agent thinks.
|
||||||
|
|
||||||
|
### What about a graphics card?
|
||||||
|
|
||||||
|
This version draws with the CPU instead of a GPU. That is enough for the simple
|
||||||
|
demo and many desktop applications. A demanding 3D game may need a future
|
||||||
|
environment that supplies a virtual or real GPU.
|
||||||
|
|
||||||
|
### Why is the demo written with Xlib?
|
||||||
|
|
||||||
|
The demo uses a tiny C/Xlib program because it has very few dependencies. It
|
||||||
|
proves the screen and input path directly. Agentbox is not tied to Xlib: a
|
||||||
|
staged application may use Raylib, SDL, Qt, GTK, a browser, or another toolkit
|
||||||
|
that can display through X11.
|
||||||
|
|
||||||
|
## Why the pieces are kept separate
|
||||||
|
|
||||||
|
Think about a toy car with replaceable batteries. The car should not care which
|
||||||
|
brand of battery powers it, and the battery should not need to know where the
|
||||||
|
car is driving.
|
||||||
|
|
||||||
|
Agentbox follows the same idea:
|
||||||
|
|
||||||
|
- The **Environment interface** is a remote control for a computer:
|
||||||
|
`Start`, `Launch`, `Screenshot`, `SendInput`, `Logs`, and `Stop`.
|
||||||
|
- The **Agent interface** receives an observation and chooses the next action.
|
||||||
|
- The **Runtime** connects the two interfaces.
|
||||||
|
|
||||||
|
This lets us swap parts independently:
|
||||||
|
|
||||||
|
- Docker today could become a microVM or cloud machine later.
|
||||||
|
- X11 today could become a Wayland or GPU backend later.
|
||||||
|
- The fixed test agent could become OpenAI, Claude, Gemini, or a local model.
|
||||||
|
- The mover game could become a browser, drawing program, or other application.
|
||||||
|
|
||||||
|
Only `internal/environment/dockerx11` knows the current Linux tricks. Model
|
||||||
|
code never imports that package.
|
||||||
|
|
||||||
|
## The two current agents
|
||||||
|
|
||||||
|
### Deterministic agent
|
||||||
|
|
||||||
|
This is a fixed test script:
|
||||||
|
|
||||||
|
1. Look at the first screenshot.
|
||||||
|
2. Hold the RIGHT key.
|
||||||
|
3. Wait one second.
|
||||||
|
4. Check another screenshot.
|
||||||
|
5. Fail unless the green square moved at least 100 pixels.
|
||||||
|
6. Release the key and finish.
|
||||||
|
|
||||||
|
It is intentionally specific to the demo. Its purpose is to test the whole
|
||||||
|
system without paying for or depending on a model API.
|
||||||
|
|
||||||
|
### OpenAI adapter
|
||||||
|
|
||||||
|
This optional adapter sends the model:
|
||||||
|
|
||||||
|
- the user's task;
|
||||||
|
- the current screenshot;
|
||||||
|
- earlier actions;
|
||||||
|
- recent application logs;
|
||||||
|
- a short history of the run.
|
||||||
|
|
||||||
|
The model must return a small JSON object containing either one allowed action
|
||||||
|
or `done`. It cannot directly call Docker or run shell commands. The adapter is
|
||||||
|
one example, not a giant framework for every model company.
|
||||||
|
|
||||||
|
## How an application gets inside
|
||||||
|
|
||||||
|
`agentbox run` accepts a prebuilt Linux executable.
|
||||||
|
|
||||||
|
If the given path is a directory, an `agentbox.json` file says which executable
|
||||||
|
to run, what arguments to pass, which environment variables to set, and
|
||||||
|
optionally which window title to wait for.
|
||||||
|
|
||||||
|
Agentbox streams the executable into temporary memory inside the container. It
|
||||||
|
does not share the developer's whole folder with the container. The temporary
|
||||||
|
copy disappears when the run ends.
|
||||||
|
|
||||||
|
There is one practical limit: the executable and its libraries must work in the
|
||||||
|
Debian-based runtime image. Agentbox does not yet package missing libraries or
|
||||||
|
convert Windows and macOS programs.
|
||||||
|
|
||||||
|
## What gets recorded
|
||||||
|
|
||||||
|
The trace is an experiment notebook:
|
||||||
|
|
||||||
|
- `run.json` says what ran, which agent controlled it, and whether it finished;
|
||||||
|
- `steps.jsonl` records every observation, decision, and action in order;
|
||||||
|
- `actions.jsonl` is a smaller action-only list;
|
||||||
|
- `screenshots/` contains the pictures the agent saw;
|
||||||
|
- `stdout.log` and `stderr.log` contain application messages.
|
||||||
|
|
||||||
|
The trace format has a version number. Screenshots are separate files instead
|
||||||
|
of giant blobs inside JSON. This keeps traces easy to inspect and leaves room
|
||||||
|
for replay, comparisons, tests, or training data later.
|
||||||
|
|
||||||
|
## Is the container safe?
|
||||||
|
|
||||||
|
Not safe enough for strangers' programs.
|
||||||
|
|
||||||
|
Docker is more like a locked bedroom than a bank vault. It keeps ordinary
|
||||||
|
programs apart, but every container still shares the host's Linux kernel. A
|
||||||
|
serious kernel or Docker bug might let a hostile program break out.
|
||||||
|
|
||||||
|
This prototype adds useful guardrails:
|
||||||
|
|
||||||
|
- no network access inside the environment;
|
||||||
|
- a read-only main filesystem;
|
||||||
|
- a small, temporary writable area;
|
||||||
|
- an unprivileged user;
|
||||||
|
- no extra Linux capabilities;
|
||||||
|
- limits on CPU, memory, and process count;
|
||||||
|
- automatic destruction after the run.
|
||||||
|
|
||||||
|
These rules reduce accidents. They do not make Docker a trustworthy boundary
|
||||||
|
for arbitrary customer code. Do not use this prototype to run unknown binaries
|
||||||
|
on an important machine or a machine shared by multiple customers.
|
||||||
|
|
||||||
|
## What production would need
|
||||||
|
|
||||||
|
Before accepting untrusted uploads, the room needs walls built with hardware
|
||||||
|
virtualization. A small virtual machine, such as Firecracker or Kata
|
||||||
|
Containers, gives each run its own kernel. gVisor may be another option when it
|
||||||
|
supports the applications we need.
|
||||||
|
|
||||||
|
A real service would also need to:
|
||||||
|
|
||||||
|
- build uploaded programs away from runtime hosts;
|
||||||
|
- limit upload size, expanded file size, disk use, runtime, and network access;
|
||||||
|
- keep every customer's files, logs, secrets, and encryption keys separate;
|
||||||
|
- authenticate every control request;
|
||||||
|
- patch and replace base images regularly;
|
||||||
|
- monitor hosts and destroy every machine after its run.
|
||||||
|
|
||||||
|
## What this experiment proves
|
||||||
|
|
||||||
|
It proves the interaction model:
|
||||||
|
|
||||||
|
1. launch a normal graphical Linux application without a physical monitor;
|
||||||
|
2. observe it through screenshots and logs;
|
||||||
|
3. control it with keyboard and mouse actions;
|
||||||
|
4. keep the environment and agent replaceable;
|
||||||
|
5. save enough information to understand what happened;
|
||||||
|
6. shut everything down cleanly.
|
||||||
|
|
||||||
|
It does **not** prove production security, GPU game support, cloud scaling,
|
||||||
|
Windows/macOS support, billing, or authentication.
|
||||||
|
|
||||||
|
## Small glossary
|
||||||
|
|
||||||
|
- **Container:** A lightweight box around processes and files.
|
||||||
|
- **Backend:** One implementation hidden behind a common interface.
|
||||||
|
- **X11:** A Linux system for drawing windows and handling input.
|
||||||
|
- **Headless:** Running without a physical monitor.
|
||||||
|
- **Synthetic input:** Keyboard or mouse events created by software.
|
||||||
|
- **Trace:** The saved record of a run.
|
||||||
|
- **Kernel:** The deepest part of the operating system that controls hardware
|
||||||
|
and processes.
|
||||||
|
- **MicroVM:** A small virtual machine with its own kernel.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [X.Org Xvfb manual](https://www.x.org/releases/X11R7.6/doc/man/man1/Xvfb.1.xhtml)
|
||||||
|
- [xdotool project and XTEST behavior](https://github.com/jordansissel/xdotool)
|
||||||
|
- [Docker Engine security](https://docs.docker.com/engine/security/)
|
||||||
|
- [gVisor security model](https://gvisor.dev/docs/architecture_guide/security/)
|
||||||
|
- [Firecracker design](https://firecracker-microvm.github.io/)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
FROM debian:bookworm-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libx11-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY examples/mover/main.c /src/main.c
|
||||||
|
RUN cc -std=c11 -O2 -Wall -Wextra -Werror /src/main.c -lX11 -o /mover
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
libx11-6 \
|
||||||
|
openbox \
|
||||||
|
scrot \
|
||||||
|
x11-utils \
|
||||||
|
xdotool \
|
||||||
|
xvfb \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd --create-home --uid 10001 agentbox
|
||||||
|
|
||||||
|
COPY --from=builder /mover /opt/agentbox/mover
|
||||||
|
COPY environment/entrypoint.sh /opt/agentbox/entrypoint.sh
|
||||||
|
RUN chmod 0755 /opt/agentbox/entrypoint.sh /opt/agentbox/mover
|
||||||
|
|
||||||
|
USER agentbox
|
||||||
|
ENV DISPLAY=:99
|
||||||
|
ENTRYPOINT ["/opt/agentbox/entrypoint.sh"]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
export HOME=/tmp/home
|
||||||
|
export XDG_RUNTIME_DIR=/tmp/runtime
|
||||||
|
mkdir -p "$HOME" "$XDG_RUNTIME_DIR"
|
||||||
|
chmod 0700 "$XDG_RUNTIME_DIR"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f /tmp/agentbox-ready
|
||||||
|
kill "${OPENBOX_PID:-}" "${XVFB_PID:-}" 2>/dev/null || true
|
||||||
|
wait "${OPENBOX_PID:-}" "${XVFB_PID:-}" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
cleanup
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap terminate INT TERM
|
||||||
|
|
||||||
|
Xvfb "$DISPLAY" -screen 0 640x480x24 -nolisten tcp -noreset &
|
||||||
|
XVFB_PID=$!
|
||||||
|
|
||||||
|
attempt=0
|
||||||
|
until xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
if [ "$attempt" -ge 100 ]; then
|
||||||
|
echo "Xvfb did not become ready" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.05
|
||||||
|
done
|
||||||
|
|
||||||
|
openbox --sm-disable >/tmp/openbox.log 2>&1 &
|
||||||
|
OPENBOX_PID=$!
|
||||||
|
|
||||||
|
attempt=0
|
||||||
|
while :; do
|
||||||
|
wm_info=$(xprop -root _NET_SUPPORTING_WM_CHECK 2>/dev/null || true)
|
||||||
|
case "$wm_info" in
|
||||||
|
*"window id #"*) break ;;
|
||||||
|
esac
|
||||||
|
if ! kill -0 "$OPENBOX_PID" 2>/dev/null; then
|
||||||
|
echo "Openbox exited before becoming ready" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
if [ "$attempt" -ge 100 ]; then
|
||||||
|
echo "Openbox did not become ready" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.05
|
||||||
|
done
|
||||||
|
|
||||||
|
touch /tmp/agentbox-ready
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
sleep 3600 &
|
||||||
|
wait $!
|
||||||
|
done
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"command": "mover",
|
||||||
|
"window_title": "Agentbox Mover"
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#define _POSIX_C_SOURCE 199309L
|
||||||
|
|
||||||
|
#include <X11/Xlib.h>
|
||||||
|
#include <X11/keysym.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
enum {
|
||||||
|
WINDOW_WIDTH = 640,
|
||||||
|
WINDOW_HEIGHT = 480,
|
||||||
|
SQUARE_SIZE = 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
static double monotonic_seconds(void) {
|
||||||
|
struct timespec now;
|
||||||
|
if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) {
|
||||||
|
perror("clock_gettime");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
return (double)now.tv_sec + (double)now.tv_nsec / 1000000000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set_key_state(KeySym key, bool down, bool *left, bool *right,
|
||||||
|
bool *up, bool *down_key) {
|
||||||
|
switch (key) {
|
||||||
|
case XK_Left:
|
||||||
|
*left = down;
|
||||||
|
break;
|
||||||
|
case XK_Right:
|
||||||
|
*right = down;
|
||||||
|
break;
|
||||||
|
case XK_Up:
|
||||||
|
*up = down;
|
||||||
|
break;
|
||||||
|
case XK_Down:
|
||||||
|
*down_key = down;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
setvbuf(stdout, NULL, _IOLBF, 0);
|
||||||
|
|
||||||
|
Display *display = XOpenDisplay(NULL);
|
||||||
|
if (display == NULL) {
|
||||||
|
fputs("unable to open X display\n", stderr);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int screen = DefaultScreen(display);
|
||||||
|
Window window = XCreateSimpleWindow(
|
||||||
|
display, RootWindow(display, screen), 0, 0, WINDOW_WIDTH, WINDOW_HEIGHT,
|
||||||
|
0, BlackPixel(display, screen), BlackPixel(display, screen));
|
||||||
|
XStoreName(display, window, "Agentbox Mover");
|
||||||
|
XSelectInput(display, window,
|
||||||
|
ExposureMask | KeyPressMask | KeyReleaseMask | ButtonPressMask);
|
||||||
|
XMapWindow(display, window);
|
||||||
|
|
||||||
|
GC gc = XCreateGC(display, window, 0, NULL);
|
||||||
|
Colormap colors = DefaultColormap(display, screen);
|
||||||
|
XColor green;
|
||||||
|
XColor red;
|
||||||
|
if (!XParseColor(display, colors, "#00ff66", &green) ||
|
||||||
|
!XAllocColor(display, colors, &green) ||
|
||||||
|
!XParseColor(display, colors, "#ff3355", &red) ||
|
||||||
|
!XAllocColor(display, colors, &red)) {
|
||||||
|
fputs("unable to allocate colors\n", stderr);
|
||||||
|
XCloseDisplay(display);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
double x = (WINDOW_WIDTH - SQUARE_SIZE) / 2.0;
|
||||||
|
double y = (WINDOW_HEIGHT - SQUARE_SIZE) / 2.0;
|
||||||
|
bool left = false;
|
||||||
|
bool right = false;
|
||||||
|
bool up = false;
|
||||||
|
bool down = false;
|
||||||
|
bool marker_visible = false;
|
||||||
|
int marker_x = 0;
|
||||||
|
int marker_y = 0;
|
||||||
|
double previous = monotonic_seconds();
|
||||||
|
|
||||||
|
printf("mover started at x=%.1f y=%.1f\n", x, y);
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
while (XPending(display) > 0) {
|
||||||
|
XEvent event;
|
||||||
|
XNextEvent(display, &event);
|
||||||
|
if (event.type == KeyPress || event.type == KeyRelease) {
|
||||||
|
set_key_state(XLookupKeysym(&event.xkey, 0),
|
||||||
|
event.type == KeyPress, &left, &right, &up, &down);
|
||||||
|
} else if (event.type == ButtonPress) {
|
||||||
|
marker_visible = true;
|
||||||
|
marker_x = event.xbutton.x;
|
||||||
|
marker_y = event.xbutton.y;
|
||||||
|
printf("mouse click at x=%d y=%d\n", marker_x, marker_y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double now = monotonic_seconds();
|
||||||
|
double delta = now - previous;
|
||||||
|
previous = now;
|
||||||
|
const double speed = 180.0;
|
||||||
|
x += ((right ? 1.0 : 0.0) - (left ? 1.0 : 0.0)) * speed * delta;
|
||||||
|
y += ((down ? 1.0 : 0.0) - (up ? 1.0 : 0.0)) * speed * delta;
|
||||||
|
|
||||||
|
if (x < 0.0) {
|
||||||
|
x = 0.0;
|
||||||
|
} else if (x > WINDOW_WIDTH - SQUARE_SIZE) {
|
||||||
|
x = WINDOW_WIDTH - SQUARE_SIZE;
|
||||||
|
}
|
||||||
|
if (y < 0.0) {
|
||||||
|
y = 0.0;
|
||||||
|
} else if (y > WINDOW_HEIGHT - SQUARE_SIZE) {
|
||||||
|
y = WINDOW_HEIGHT - SQUARE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
XClearWindow(display, window);
|
||||||
|
XSetForeground(display, gc, green.pixel);
|
||||||
|
XFillRectangle(display, window, gc, (int)x, (int)y, SQUARE_SIZE,
|
||||||
|
SQUARE_SIZE);
|
||||||
|
if (marker_visible) {
|
||||||
|
XSetForeground(display, gc, red.pixel);
|
||||||
|
XFillRectangle(display, window, gc, marker_x - 4, marker_y - 4, 9,
|
||||||
|
9);
|
||||||
|
}
|
||||||
|
XFlush(display);
|
||||||
|
|
||||||
|
struct timespec frame = {.tv_sec = 0, .tv_nsec = 16000000};
|
||||||
|
nanosleep(&frame, NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module agentbox
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Observation struct {
|
||||||
|
Screenshot []byte
|
||||||
|
Timestamp time.Time
|
||||||
|
Logs []environment.LogEntry
|
||||||
|
PreviousActions []environment.InputAction
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is the compact history passed back to an agent on its next decision.
|
||||||
|
type Step struct {
|
||||||
|
Number int
|
||||||
|
Timestamp time.Time
|
||||||
|
ScreenshotPath string
|
||||||
|
Message string
|
||||||
|
Action *environment.InputAction
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision contains either one action or Done. Returning neither is invalid.
|
||||||
|
type Decision struct {
|
||||||
|
Reason string
|
||||||
|
Action *environment.InputAction
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent chooses one backend-neutral action from the latest observation.
|
||||||
|
type Agent interface {
|
||||||
|
Name() string
|
||||||
|
NextAction(
|
||||||
|
ctx context.Context,
|
||||||
|
task string,
|
||||||
|
history []Step,
|
||||||
|
observation Observation,
|
||||||
|
) (Decision, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Deterministic struct {
|
||||||
|
nextStep int
|
||||||
|
initialSquareX float64
|
||||||
|
hasInitialPosition bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (deterministicAgent *Deterministic) Name() string {
|
||||||
|
return "deterministic-right"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (deterministicAgent *Deterministic) NextAction(
|
||||||
|
_ context.Context,
|
||||||
|
_ string,
|
||||||
|
_ []Step,
|
||||||
|
observation Observation,
|
||||||
|
) (Decision, error) {
|
||||||
|
var decision Decision
|
||||||
|
switch deterministicAgent.nextStep {
|
||||||
|
case 0:
|
||||||
|
initialSquareX, err := greenCentroidX(observation.Screenshot)
|
||||||
|
if err != nil {
|
||||||
|
return Decision{}, fmt.Errorf("inspect initial screenshot: %w", err)
|
||||||
|
}
|
||||||
|
deterministicAgent.initialSquareX = initialSquareX
|
||||||
|
deterministicAgent.hasInitialPosition = true
|
||||||
|
action := environment.InputAction{Type: environment.KeyDown, Key: "RIGHT"}
|
||||||
|
decision = Decision{Reason: "Press RIGHT to start moving.", Action: &action}
|
||||||
|
case 1:
|
||||||
|
action := environment.InputAction{Type: environment.Wait, DurationMS: 1000}
|
||||||
|
decision = Decision{Reason: "Keep RIGHT held for one second.", Action: &action}
|
||||||
|
case 2:
|
||||||
|
currentSquareX, err := greenCentroidX(observation.Screenshot)
|
||||||
|
if err != nil {
|
||||||
|
return Decision{}, fmt.Errorf("inspect moved screenshot: %w", err)
|
||||||
|
}
|
||||||
|
distanceMoved := currentSquareX - deterministicAgent.initialSquareX
|
||||||
|
if !deterministicAgent.hasInitialPosition || distanceMoved < 100 {
|
||||||
|
return Decision{}, fmt.Errorf(
|
||||||
|
"visual verification failed: square moved %.1f pixels right, want at least 100",
|
||||||
|
distanceMoved,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
action := environment.InputAction{Type: environment.KeyUp, Key: "RIGHT"}
|
||||||
|
decision = Decision{
|
||||||
|
Reason: fmt.Sprintf(
|
||||||
|
"The square moved %.1f pixels right; release RIGHT.",
|
||||||
|
distanceMoved,
|
||||||
|
),
|
||||||
|
Action: &action,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
decision = Decision{Reason: "The movement sequence is complete.", Done: true}
|
||||||
|
}
|
||||||
|
deterministicAgent.nextStep++
|
||||||
|
return decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func greenCentroidX(screenshot []byte) (float64, error) {
|
||||||
|
renderedImage, err := png.Decode(bytes.NewReader(screenshot))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var xCoordinateSum, greenPixelCount uint64
|
||||||
|
bounds := renderedImage.Bounds()
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
red, green, blue, _ := renderedImage.At(x, y).RGBA()
|
||||||
|
// Match the mover's #00ff66 square. Requiring 1,000 matching pixels
|
||||||
|
// prevents a small green UI detail from passing verification.
|
||||||
|
if green > 0xc000 && red < 0x4000 && blue < 0x8000 {
|
||||||
|
xCoordinateSum += uint64(x)
|
||||||
|
greenPixelCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if greenPixelCount < 1000 {
|
||||||
|
return 0, fmt.Errorf("found only %d green square pixels", greenPixelCount)
|
||||||
|
}
|
||||||
|
return float64(xCoordinateSum) / float64(greenPixelCount), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Agent = (*Deterministic)(nil)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeterministicSequence(t *testing.T) {
|
||||||
|
controller := &Deterministic{}
|
||||||
|
want := []struct {
|
||||||
|
action environment.InputType
|
||||||
|
done bool
|
||||||
|
}{
|
||||||
|
{environment.KeyDown, false},
|
||||||
|
{environment.Wait, false},
|
||||||
|
{environment.KeyUp, false},
|
||||||
|
{"", true},
|
||||||
|
}
|
||||||
|
for index, expected := range want {
|
||||||
|
x := 20
|
||||||
|
if index >= 2 {
|
||||||
|
x = 140
|
||||||
|
}
|
||||||
|
decision, err := controller.NextAction(context.Background(), "", nil, Observation{
|
||||||
|
Screenshot: screenshotWithSquare(t, x),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("step %d: %v", index, err)
|
||||||
|
}
|
||||||
|
if decision.Done != expected.done {
|
||||||
|
t.Fatalf("step %d done = %v, want %v", index, decision.Done, expected.done)
|
||||||
|
}
|
||||||
|
if expected.action != "" && (decision.Action == nil || decision.Action.Type != expected.action) {
|
||||||
|
t.Fatalf("step %d action = %#v, want %s", index, decision.Action, expected.action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeterministicRejectsMissingMovement(t *testing.T) {
|
||||||
|
controller := &Deterministic{}
|
||||||
|
for step := 0; step < 2; step++ {
|
||||||
|
if _, err := controller.NextAction(context.Background(), "", nil, Observation{
|
||||||
|
Screenshot: screenshotWithSquare(t, 20),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := controller.NextAction(context.Background(), "", nil, Observation{
|
||||||
|
Screenshot: screenshotWithSquare(t, 20),
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("movement verification error = nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func screenshotWithSquare(t *testing.T, startX int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 240, 100))
|
||||||
|
green := color.RGBA{G: 255, B: 102, A: 255}
|
||||||
|
for y := 20; y < 60; y++ {
|
||||||
|
for x := startX; x < startX+40; x++ {
|
||||||
|
img.Set(x, y, green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := png.Encode(&output, img); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return output.Bytes()
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OpenAIConfig struct {
|
||||||
|
APIKey string
|
||||||
|
Model string
|
||||||
|
BaseURL string
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI adapts the Responses API to Agentbox's provider-neutral Agent
|
||||||
|
// contract. HTTP and wire-format details do not leak into the runtime.
|
||||||
|
type OpenAI struct {
|
||||||
|
config OpenAIConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenAI(config OpenAIConfig) (*OpenAI, error) {
|
||||||
|
if config.APIKey == "" {
|
||||||
|
return nil, errors.New("OPENAI_API_KEY is required for --agent openai")
|
||||||
|
}
|
||||||
|
if config.Model == "" {
|
||||||
|
config.Model = "gpt-5"
|
||||||
|
}
|
||||||
|
if config.BaseURL == "" {
|
||||||
|
config.BaseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
if config.Client == nil {
|
||||||
|
config.Client = &http.Client{Timeout: 90 * time.Second}
|
||||||
|
}
|
||||||
|
return &OpenAI{config: config}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (openAI *OpenAI) Name() string {
|
||||||
|
return "openai:" + openAI.config.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func (openAI *OpenAI) NextAction(
|
||||||
|
ctx context.Context,
|
||||||
|
task string,
|
||||||
|
history []Step,
|
||||||
|
observation Observation,
|
||||||
|
) (Decision, error) {
|
||||||
|
requestBody, err := buildResponsesRequest(
|
||||||
|
openAI.config.Model,
|
||||||
|
task,
|
||||||
|
history,
|
||||||
|
observation,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Decision{}, err
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
strings.TrimRight(openAI.config.BaseURL, "/")+"/responses",
|
||||||
|
bytes.NewReader(requestBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Decision{}, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+openAI.config.APIKey)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
httpResponse, err := openAI.config.Client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return Decision{}, err
|
||||||
|
}
|
||||||
|
defer httpResponse.Body.Close()
|
||||||
|
|
||||||
|
responseBody, err := io.ReadAll(io.LimitReader(httpResponse.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return Decision{}, err
|
||||||
|
}
|
||||||
|
if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
|
||||||
|
return Decision{}, fmt.Errorf(
|
||||||
|
"OpenAI response %s: %s",
|
||||||
|
httpResponse.Status,
|
||||||
|
strings.TrimSpace(string(responseBody)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
outputText, err := extractResponseText(responseBody)
|
||||||
|
if err != nil {
|
||||||
|
return Decision{}, err
|
||||||
|
}
|
||||||
|
return parseModelDecision(outputText)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Agent = (*OpenAI)(nil)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
type responsesRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Input []responsesInput `json:"input"`
|
||||||
|
Text responsesTextSettings `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type responsesInput struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []responsesContent `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type responsesContent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type responsesTextSettings struct {
|
||||||
|
Format responsesFormat `json:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type responsesFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Strict bool `json:"strict"`
|
||||||
|
Schema map[string]any `json:"schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildResponsesRequest(
|
||||||
|
model string,
|
||||||
|
task string,
|
||||||
|
history []Step,
|
||||||
|
observation Observation,
|
||||||
|
) ([]byte, error) {
|
||||||
|
prompt, err := modelPrompt(task, history, observation)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request := responsesRequest{
|
||||||
|
Model: model,
|
||||||
|
Input: []responsesInput{{
|
||||||
|
Role: "user",
|
||||||
|
Content: []responsesContent{
|
||||||
|
{Type: "input_text", Text: prompt},
|
||||||
|
{
|
||||||
|
Type: "input_image",
|
||||||
|
ImageURL: "data:image/png;base64," +
|
||||||
|
base64.StdEncoding.EncodeToString(observation.Screenshot),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
Text: responsesTextSettings{Format: responsesFormat{
|
||||||
|
Type: "json_schema",
|
||||||
|
Name: "agentbox_action",
|
||||||
|
Strict: true,
|
||||||
|
Schema: decisionSchema(),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
return json.Marshal(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelPrompt(task string, history []Step, observation Observation) (string, error) {
|
||||||
|
contextData := struct {
|
||||||
|
Task string `json:"task"`
|
||||||
|
History []Step `json:"history"`
|
||||||
|
RecentLogs []environment.LogEntry `json:"recent_logs"`
|
||||||
|
PreviousActions []environment.InputAction `json:"previous_actions"`
|
||||||
|
}{
|
||||||
|
Task: task,
|
||||||
|
History: history,
|
||||||
|
RecentLogs: observation.Logs,
|
||||||
|
PreviousActions: observation.PreviousActions,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(contextData)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "You control an interactive Linux application from screenshots. " +
|
||||||
|
"Choose exactly one allowed input action, or mark done when the task is complete. " +
|
||||||
|
"Use logical key names such as RIGHT, ENTER, or ESCAPE. Keep waits under 5000 ms.\n" +
|
||||||
|
string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decisionSchema() map[string]any {
|
||||||
|
// Strict structured output requires every action property to be present,
|
||||||
|
// even when a particular action ignores most of them.
|
||||||
|
actionProperties := map[string]any{
|
||||||
|
"type": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{
|
||||||
|
string(environment.KeyDown), string(environment.KeyUp),
|
||||||
|
string(environment.MouseMove), string(environment.MouseDown),
|
||||||
|
string(environment.MouseUp), string(environment.Wait),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"key": map[string]any{"type": "string"},
|
||||||
|
"x": map[string]any{"type": "integer"},
|
||||||
|
"y": map[string]any{"type": "integer"},
|
||||||
|
"button": map[string]any{"type": "integer"},
|
||||||
|
"duration_ms": map[string]any{"type": "integer"},
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": map[string]any{
|
||||||
|
"reason": map[string]any{"type": "string"},
|
||||||
|
"done": map[string]any{"type": "boolean"},
|
||||||
|
"action": map[string]any{
|
||||||
|
"anyOf": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": actionProperties,
|
||||||
|
"required": []string{
|
||||||
|
"type", "key", "x", "y", "button", "duration_ms",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]any{"type": "null"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"reason", "done", "action"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractResponseText(data []byte) (string, error) {
|
||||||
|
var response struct {
|
||||||
|
Output []struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"output"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &response); err != nil {
|
||||||
|
return "", fmt.Errorf("decode OpenAI response: %w", err)
|
||||||
|
}
|
||||||
|
for _, output := range response.Output {
|
||||||
|
for _, content := range output.Content {
|
||||||
|
if content.Type == "output_text" && content.Text != "" {
|
||||||
|
return content.Text, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("OpenAI response contained no output_text")
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelDecision struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
Action *modelAction `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelAction struct {
|
||||||
|
Type environment.InputType `json:"type"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
X int `json:"x"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
Button int `json:"button"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseModelDecision(text string) (Decision, error) {
|
||||||
|
var modelOutput modelDecision
|
||||||
|
if err := json.Unmarshal([]byte(text), &modelOutput); err != nil {
|
||||||
|
return Decision{}, fmt.Errorf("decode model decision: %w", err)
|
||||||
|
}
|
||||||
|
if modelOutput.Reason == "" {
|
||||||
|
return Decision{}, errors.New("model decision requires reason")
|
||||||
|
}
|
||||||
|
if modelOutput.Done {
|
||||||
|
return Decision{Reason: modelOutput.Reason, Done: true}, nil
|
||||||
|
}
|
||||||
|
if modelOutput.Action == nil {
|
||||||
|
return Decision{}, errors.New("model decision requires action when not done")
|
||||||
|
}
|
||||||
|
if modelOutput.Action.DurationMS < 0 || modelOutput.Action.DurationMS > 5000 {
|
||||||
|
return Decision{}, errors.New("model wait must be between 0 and 5000 ms")
|
||||||
|
}
|
||||||
|
action := environment.InputAction{
|
||||||
|
Type: modelOutput.Action.Type,
|
||||||
|
Key: modelOutput.Action.Key,
|
||||||
|
X: modelOutput.Action.X,
|
||||||
|
Y: modelOutput.Action.Y,
|
||||||
|
Button: modelOutput.Action.Button,
|
||||||
|
DurationMS: modelOutput.Action.DurationMS,
|
||||||
|
}
|
||||||
|
switch action.Type {
|
||||||
|
case environment.KeyDown, environment.KeyUp, environment.MouseMove,
|
||||||
|
environment.MouseDown, environment.MouseUp, environment.Wait:
|
||||||
|
default:
|
||||||
|
return Decision{}, fmt.Errorf("model returned unsupported action %q", action.Type)
|
||||||
|
}
|
||||||
|
return Decision{Reason: modelOutput.Reason, Action: &action}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenAISendsScreenshotAndParsesDecision(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path != "/responses" {
|
||||||
|
t.Errorf("path = %q, want /responses", request.URL.Path)
|
||||||
|
}
|
||||||
|
if request.Header.Get("Authorization") != "Bearer test-key" {
|
||||||
|
t.Errorf("missing bearer token")
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(request.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "data:image/png;base64,AQID") {
|
||||||
|
t.Errorf("request does not contain screenshot data URL: %s", body)
|
||||||
|
}
|
||||||
|
response.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(response, `{
|
||||||
|
"output":[{"content":[{"type":"output_text","text":"{\"reason\":\"move right\",\"done\":false,\"action\":{\"type\":\"key_down\",\"key\":\"RIGHT\",\"x\":0,\"y\":0,\"button\":0,\"duration_ms\":0}}"}]}]
|
||||||
|
}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
controller, err := NewOpenAI(OpenAIConfig{
|
||||||
|
APIKey: "test-key", Model: "test-model", BaseURL: server.URL, Client: server.Client(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decision, err := controller.NextAction(context.Background(), "move", nil, Observation{
|
||||||
|
Screenshot: []byte{1, 2, 3},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decision.Action == nil || decision.Action.Type != environment.KeyDown ||
|
||||||
|
decision.Action.Key != "RIGHT" {
|
||||||
|
t.Fatalf("decision = %#v", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecisionSchemaIsJSONSerializable(t *testing.T) {
|
||||||
|
if _, err := json.Marshal(decisionSchema()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package appspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EnvironmentVariables map[string]string `json:"env"`
|
||||||
|
WindowTitle string `json:"window_title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve turns either an executable path or a directory containing
|
||||||
|
// agentbox.json into the backend-neutral launch command.
|
||||||
|
func Resolve(path string) (environment.Command, error) {
|
||||||
|
absolute, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return environment.Command{}, fmt.Errorf("resolve application path: %w", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(absolute)
|
||||||
|
if err != nil {
|
||||||
|
return environment.Command{}, fmt.Errorf("inspect application path: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return environment.Command{}, errors.New("application must be a regular executable file")
|
||||||
|
}
|
||||||
|
return environment.Command{Path: absolute}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(absolute, "agentbox.json"))
|
||||||
|
if err != nil {
|
||||||
|
return environment.Command{}, fmt.Errorf("read directory manifest agentbox.json: %w", err)
|
||||||
|
}
|
||||||
|
var config manifest
|
||||||
|
if err := json.Unmarshal(data, &config); err != nil {
|
||||||
|
return environment.Command{}, fmt.Errorf("parse agentbox.json: %w", err)
|
||||||
|
}
|
||||||
|
if config.Command == "" {
|
||||||
|
return environment.Command{}, errors.New("agentbox.json requires command")
|
||||||
|
}
|
||||||
|
commandPath := config.Command
|
||||||
|
if !filepath.IsAbs(commandPath) {
|
||||||
|
commandPath = filepath.Join(absolute, commandPath)
|
||||||
|
}
|
||||||
|
commandInfo, err := os.Stat(commandPath)
|
||||||
|
if err != nil {
|
||||||
|
return environment.Command{}, fmt.Errorf("inspect manifest command: %w", err)
|
||||||
|
}
|
||||||
|
if !commandInfo.Mode().IsRegular() {
|
||||||
|
return environment.Command{}, errors.New("manifest command must be a regular executable file")
|
||||||
|
}
|
||||||
|
return environment.Command{
|
||||||
|
Path: commandPath,
|
||||||
|
Args: config.Args,
|
||||||
|
EnvironmentVariables: config.EnvironmentVariables,
|
||||||
|
WindowTitle: config.WindowTitle,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package appspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveDirectoryManifest(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
executable := filepath.Join(directory, "game")
|
||||||
|
if err := os.WriteFile(executable, []byte("binary"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifest := `{"command":"game","args":["--demo"],"window_title":"Demo"}`
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "agentbox.json"), []byte(manifest), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
command, err := Resolve(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if command.Path != executable || command.WindowTitle != "Demo" ||
|
||||||
|
len(command.Args) != 1 || command.Args[0] != "--demo" {
|
||||||
|
t.Fatalf("command = %#v", command)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package dockerx11
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
const stagedApplicationPath = "/tmp/application"
|
||||||
|
|
||||||
|
func (environmentBackend *Environment) Launch(
|
||||||
|
ctx context.Context,
|
||||||
|
application environment.Command,
|
||||||
|
) error {
|
||||||
|
fmt.Fprintln(environmentBackend.config.Output, "Staging application...")
|
||||||
|
executable, err := os.Open(application.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open application: %w", err)
|
||||||
|
}
|
||||||
|
defer executable.Close()
|
||||||
|
|
||||||
|
// Stream instead of bind-mounting the developer's directory. The container
|
||||||
|
// sees only the requested executable, and the copy disappears at teardown.
|
||||||
|
stageCommand := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"-i",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"cat > "+stagedApplicationPath+" && chmod 0500 "+stagedApplicationPath,
|
||||||
|
)
|
||||||
|
stageCommand.Stdin = executable
|
||||||
|
if output, err := stageCommand.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"stage application: %w: %s",
|
||||||
|
err,
|
||||||
|
strings.TrimSpace(string(output)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(environmentBackend.config.Output, "Launching application...")
|
||||||
|
dockerArguments := []string{"exec", "-d"}
|
||||||
|
for variableName, variableValue := range application.EnvironmentVariables {
|
||||||
|
dockerArguments = append(
|
||||||
|
dockerArguments,
|
||||||
|
"-e",
|
||||||
|
variableName+"="+variableValue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dockerArguments = append(
|
||||||
|
dockerArguments,
|
||||||
|
environmentBackend.containerName,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
)
|
||||||
|
shellCommand := []string{"exec", stagedApplicationPath}
|
||||||
|
for _, applicationArgument := range application.Args {
|
||||||
|
shellCommand = append(shellCommand, shellQuote(applicationArgument))
|
||||||
|
}
|
||||||
|
shellCommand = append(
|
||||||
|
shellCommand,
|
||||||
|
">/tmp/stdout.log",
|
||||||
|
"2>/tmp/stderr.log",
|
||||||
|
)
|
||||||
|
dockerArguments = append(dockerArguments, strings.Join(shellCommand, " "))
|
||||||
|
if _, err := environmentBackend.runDocker(ctx, dockerArguments...); err != nil {
|
||||||
|
return fmt.Errorf("launch application: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return environmentBackend.focusApplicationWindow(ctx, application.WindowTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (environmentBackend *Environment) focusApplicationWindow(
|
||||||
|
ctx context.Context,
|
||||||
|
windowTitle string,
|
||||||
|
) error {
|
||||||
|
if windowTitle == "" {
|
||||||
|
// Applications without a title cannot be searched reliably. Give the
|
||||||
|
// process a brief startup window before the first screenshot.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowID string
|
||||||
|
if err := waitFor(ctx, 10*time.Second, func() bool {
|
||||||
|
output, searchErr := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"exec",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
"xdotool",
|
||||||
|
"search",
|
||||||
|
"--name",
|
||||||
|
windowTitle,
|
||||||
|
)
|
||||||
|
if searchErr != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
windowID = strings.TrimSpace(strings.Split(string(output), "\n")[0])
|
||||||
|
return windowID != ""
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("wait for application window %q: %w", windowTitle, err)
|
||||||
|
}
|
||||||
|
if _, err := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"exec",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
"xdotool",
|
||||||
|
"windowactivate",
|
||||||
|
"--sync",
|
||||||
|
windowID,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("focus application window: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (environmentBackend *Environment) Screenshot(ctx context.Context) ([]byte, error) {
|
||||||
|
const screenshotPath = "/tmp/screenshot.png"
|
||||||
|
if _, err := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"exec",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
"scrot",
|
||||||
|
"-o",
|
||||||
|
screenshotPath,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("capture screenshot: %w", err)
|
||||||
|
}
|
||||||
|
screenshot, err := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"exec",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
"cat",
|
||||||
|
screenshotPath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract screenshot: %w", err)
|
||||||
|
}
|
||||||
|
return screenshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (environmentBackend *Environment) Logs(
|
||||||
|
ctx context.Context,
|
||||||
|
) ([]environment.LogEntry, error) {
|
||||||
|
var logEntries []environment.LogEntry
|
||||||
|
for _, streamName := range []string{"stdout", "stderr"} {
|
||||||
|
logContents, err := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"exec",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
"cat",
|
||||||
|
"/tmp/"+streamName+".log",
|
||||||
|
)
|
||||||
|
if err != nil || len(logContents) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logEntries = append(logEntries, environment.LogEntry{
|
||||||
|
Stream: streamName,
|
||||||
|
Message: string(logContents),
|
||||||
|
Time: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return logEntries, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package dockerx11
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (environmentBackend *Environment) runDocker(
|
||||||
|
ctx context.Context,
|
||||||
|
arguments ...string,
|
||||||
|
) ([]byte, error) {
|
||||||
|
command := exec.CommandContext(ctx, "docker", arguments...)
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return output, fmt.Errorf(
|
||||||
|
"docker %s: %w: %s",
|
||||||
|
arguments[0],
|
||||||
|
err,
|
||||||
|
strings.TrimSpace(string(output)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (environmentBackend *Environment) streamDockerOutput(
|
||||||
|
ctx context.Context,
|
||||||
|
arguments ...string,
|
||||||
|
) error {
|
||||||
|
command := exec.CommandContext(ctx, "docker", arguments...)
|
||||||
|
command.Stdout = environmentBackend.config.Output
|
||||||
|
command.Stderr = environmentBackend.config.Output
|
||||||
|
return command.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(ctx context.Context, timeout time.Duration, ready func() bool) error {
|
||||||
|
timeoutTimer := time.NewTimer(timeout)
|
||||||
|
defer timeoutTimer.Stop()
|
||||||
|
retryTicker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer retryTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
if ready() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timeoutTimer.C:
|
||||||
|
return errors.New("timed out")
|
||||||
|
case <-retryTicker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package dockerx11
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
const runtimeImageName = "agentbox-runtime:local"
|
||||||
|
|
||||||
|
// Config contains host-side values needed to create one isolated desktop.
|
||||||
|
type Config struct {
|
||||||
|
ProjectRoot string
|
||||||
|
RunID string
|
||||||
|
Output io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment implements the backend-neutral environment contract with one
|
||||||
|
// Docker container, one X11 display, and one application process.
|
||||||
|
type Environment struct {
|
||||||
|
config Config
|
||||||
|
containerName string
|
||||||
|
|
||||||
|
stopMutex sync.Mutex
|
||||||
|
containerCreated bool
|
||||||
|
containerStopped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(config Config) *Environment {
|
||||||
|
return &Environment{
|
||||||
|
config: config,
|
||||||
|
containerName: "agentbox-run-" + config.RunID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ environment.Environment = (*Environment)(nil)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package dockerx11
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestX11KeyTranslatesLogicalNames(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"RIGHT": "Right",
|
||||||
|
"LEFT": "Left",
|
||||||
|
"ENTER": "Return",
|
||||||
|
"ESCAPE": "Escape",
|
||||||
|
"A": "a",
|
||||||
|
}
|
||||||
|
for input, want := range tests {
|
||||||
|
if got := toX11KeyName(input); got != want {
|
||||||
|
t.Errorf("toX11KeyName(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package dockerx11
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (environmentBackend *Environment) SendInput(
|
||||||
|
ctx context.Context,
|
||||||
|
action environment.InputAction,
|
||||||
|
) error {
|
||||||
|
var xdotoolArguments []string
|
||||||
|
switch action.Type {
|
||||||
|
case environment.KeyDown:
|
||||||
|
if action.Key == "" {
|
||||||
|
return errors.New("key_down requires key")
|
||||||
|
}
|
||||||
|
xdotoolArguments = []string{"keydown", toX11KeyName(action.Key)}
|
||||||
|
case environment.KeyUp:
|
||||||
|
if action.Key == "" {
|
||||||
|
return errors.New("key_up requires key")
|
||||||
|
}
|
||||||
|
xdotoolArguments = []string{"keyup", toX11KeyName(action.Key)}
|
||||||
|
case environment.MouseMove:
|
||||||
|
xdotoolArguments = []string{
|
||||||
|
"mousemove",
|
||||||
|
strconv.Itoa(action.X),
|
||||||
|
strconv.Itoa(action.Y),
|
||||||
|
}
|
||||||
|
case environment.MouseDown:
|
||||||
|
xdotoolArguments = []string{"mousedown", strconv.Itoa(action.Button)}
|
||||||
|
case environment.MouseUp:
|
||||||
|
xdotoolArguments = []string{"mouseup", strconv.Itoa(action.Button)}
|
||||||
|
case environment.Wait:
|
||||||
|
if action.DurationMS < 0 {
|
||||||
|
return errors.New("wait duration cannot be negative")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(time.Duration(action.DurationMS) * time.Millisecond):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported input action %q", action.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerArguments := []string{
|
||||||
|
"exec",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
"xdotool",
|
||||||
|
}
|
||||||
|
dockerArguments = append(dockerArguments, xdotoolArguments...)
|
||||||
|
if _, err := environmentBackend.runDocker(ctx, dockerArguments...); err != nil {
|
||||||
|
return fmt.Errorf("send %s: %w", action.Type, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toX11KeyName keeps X11 spellings out of the public action API. Agents can use
|
||||||
|
// logical names such as RIGHT even though xdotool expects Right.
|
||||||
|
func toX11KeyName(logicalName string) string {
|
||||||
|
switch strings.ToUpper(logicalName) {
|
||||||
|
case "LEFT":
|
||||||
|
return "Left"
|
||||||
|
case "RIGHT":
|
||||||
|
return "Right"
|
||||||
|
case "UP":
|
||||||
|
return "Up"
|
||||||
|
case "DOWN":
|
||||||
|
return "Down"
|
||||||
|
case "ENTER", "RETURN":
|
||||||
|
return "Return"
|
||||||
|
case "ESC", "ESCAPE":
|
||||||
|
return "Escape"
|
||||||
|
case "SPACE":
|
||||||
|
return "space"
|
||||||
|
case "TAB":
|
||||||
|
return "Tab"
|
||||||
|
case "BACKSPACE":
|
||||||
|
return "BackSpace"
|
||||||
|
case "DELETE":
|
||||||
|
return "Delete"
|
||||||
|
}
|
||||||
|
if len(logicalName) == 1 {
|
||||||
|
return strings.ToLower(logicalName)
|
||||||
|
}
|
||||||
|
return logicalName
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package dockerx11
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (environmentBackend *Environment) Start(ctx context.Context) error {
|
||||||
|
if environmentBackend.config.Output == nil {
|
||||||
|
environmentBackend.config.Output = io.Discard
|
||||||
|
}
|
||||||
|
if _, err := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"version",
|
||||||
|
"--format",
|
||||||
|
"{{.Server.Version}}",
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("Docker is required and the daemon must be accessible: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(environmentBackend.config.Output, "Creating environment...")
|
||||||
|
dockerfilePath := filepath.Join(
|
||||||
|
environmentBackend.config.ProjectRoot,
|
||||||
|
"environment",
|
||||||
|
"Dockerfile",
|
||||||
|
)
|
||||||
|
if err := environmentBackend.streamDockerOutput(
|
||||||
|
ctx,
|
||||||
|
"build",
|
||||||
|
"-q",
|
||||||
|
"-t",
|
||||||
|
runtimeImageName,
|
||||||
|
"-f",
|
||||||
|
dockerfilePath,
|
||||||
|
environmentBackend.config.ProjectRoot,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("build environment image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// These restrictions reduce accidental damage. They are defense in depth,
|
||||||
|
// not a safe boundary for hostile customer code; see docs/architecture.md.
|
||||||
|
_, err := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"create",
|
||||||
|
"--name", environmentBackend.containerName,
|
||||||
|
"--init",
|
||||||
|
"--network=none",
|
||||||
|
"--read-only",
|
||||||
|
"--tmpfs=/tmp:rw,exec,nosuid,nodev,size=128m",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges",
|
||||||
|
"--pids-limit=128",
|
||||||
|
"--memory=512m",
|
||||||
|
"--cpus=1",
|
||||||
|
runtimeImageName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create environment: %w", err)
|
||||||
|
}
|
||||||
|
environmentBackend.containerCreated = true
|
||||||
|
|
||||||
|
if _, err := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"start",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("start environment: %w", err)
|
||||||
|
}
|
||||||
|
// entrypoint.sh creates this file only after both Xvfb and Openbox accept
|
||||||
|
// requests. It is the readiness handshake between host and container.
|
||||||
|
if err := waitFor(ctx, 10*time.Second, func() bool {
|
||||||
|
_, readyErr := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"exec",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
"test",
|
||||||
|
"-f",
|
||||||
|
"/tmp/agentbox-ready",
|
||||||
|
)
|
||||||
|
return readyErr == nil
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("wait for graphical environment: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop is idempotent because both normal completion and deferred cleanup may
|
||||||
|
// try to tear down the same environment.
|
||||||
|
func (environmentBackend *Environment) Stop(ctx context.Context) error {
|
||||||
|
environmentBackend.stopMutex.Lock()
|
||||||
|
defer environmentBackend.stopMutex.Unlock()
|
||||||
|
|
||||||
|
if !environmentBackend.containerCreated || environmentBackend.containerStopped {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
environmentBackend.containerStopped = true
|
||||||
|
fmt.Fprintln(environmentBackend.config.Output, "Shutting environment down...")
|
||||||
|
|
||||||
|
_, stopErr := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"stop",
|
||||||
|
"--time=3",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
)
|
||||||
|
_, removeErr := environmentBackend.runDocker(
|
||||||
|
ctx,
|
||||||
|
"rm",
|
||||||
|
"-f",
|
||||||
|
environmentBackend.containerName,
|
||||||
|
)
|
||||||
|
if stopErr != nil {
|
||||||
|
return fmt.Errorf("stop environment: %w", stopErr)
|
||||||
|
}
|
||||||
|
if removeErr != nil {
|
||||||
|
return fmt.Errorf("remove environment: %w", removeErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package environment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command describes a host executable and how it should start in an
|
||||||
|
// environment. Environment implementations decide how to stage the file.
|
||||||
|
type Command struct {
|
||||||
|
Path string
|
||||||
|
Args []string
|
||||||
|
EnvironmentVariables map[string]string
|
||||||
|
WindowTitle string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputType identifies one backend-neutral keyboard, mouse, or timing action.
|
||||||
|
type InputType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KeyDown InputType = "key_down"
|
||||||
|
KeyUp InputType = "key_up"
|
||||||
|
MouseMove InputType = "mouse_move"
|
||||||
|
MouseDown InputType = "mouse_down"
|
||||||
|
MouseUp InputType = "mouse_up"
|
||||||
|
Wait InputType = "wait"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputAction contains only fields relevant to Type. DurationMS is used by Wait.
|
||||||
|
type InputAction struct {
|
||||||
|
Type InputType `json:"type"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
X int `json:"x,omitempty"`
|
||||||
|
Y int `json:"y,omitempty"`
|
||||||
|
Button int `json:"button,omitempty"`
|
||||||
|
DurationMS int `json:"duration_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogEntry is a cumulative snapshot of one application output stream. Time is
|
||||||
|
// when Agentbox captured the snapshot, not when the application emitted it.
|
||||||
|
type LogEntry struct {
|
||||||
|
Stream string `json:"stream"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment is the programmable-computer boundary used by the runtime.
|
||||||
|
// Implementations may use containers, virtual machines, or remote hosts.
|
||||||
|
type Environment interface {
|
||||||
|
Start(context.Context) error
|
||||||
|
Launch(context.Context, Command) error
|
||||||
|
Screenshot(context.Context) ([]byte, error)
|
||||||
|
SendInput(context.Context, InputAction) error
|
||||||
|
Logs(context.Context) ([]LogEntry, error)
|
||||||
|
Stop(context.Context) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package phase1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageName = "agentbox-phase1:local"
|
||||||
|
|
||||||
|
type point struct {
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
FinishedAt time.Time `json:"finished_at"`
|
||||||
|
ContainerEngine string `json:"container_engine"`
|
||||||
|
Before *point `json:"square_before,omitempty"`
|
||||||
|
After *point `json:"square_after,omitempty"`
|
||||||
|
MovementPixels float64 `json:"movement_pixels,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type actionRecord struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
X int `json:"x,omitempty"`
|
||||||
|
Y int `json:"y,omitempty"`
|
||||||
|
Button int `json:"button,omitempty"`
|
||||||
|
Duration string `json:"duration,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runner struct {
|
||||||
|
root string
|
||||||
|
runDir string
|
||||||
|
containerName string
|
||||||
|
containerMade bool
|
||||||
|
actions *os.File
|
||||||
|
record runRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context) (runErr error) {
|
||||||
|
root, err := findRoot()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := runID()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create run ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runDir := filepath.Join(root, ".agentbox", "runs", id)
|
||||||
|
if err := os.MkdirAll(filepath.Join(runDir, "screenshots"), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create run directory: %w", err)
|
||||||
|
}
|
||||||
|
actions, err := os.Create(filepath.Join(runDir, "actions.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create action trace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &runner{
|
||||||
|
root: root,
|
||||||
|
runDir: runDir,
|
||||||
|
containerName: "agentbox-phase1-" + id,
|
||||||
|
actions: actions,
|
||||||
|
record: runRecord{
|
||||||
|
ID: id,
|
||||||
|
Phase: "environment-prototype",
|
||||||
|
Status: "running",
|
||||||
|
StartedAt: time.Now().UTC(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
cleanupErr := r.cleanup()
|
||||||
|
r.record.FinishedAt = time.Now().UTC()
|
||||||
|
if runErr == nil && cleanupErr != nil {
|
||||||
|
runErr = cleanupErr
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
r.record.Status = "failed"
|
||||||
|
r.record.Error = runErr.Error()
|
||||||
|
} else {
|
||||||
|
r.record.Status = "passed"
|
||||||
|
}
|
||||||
|
if err := r.writeRecord(); err != nil && runErr == nil {
|
||||||
|
runErr = err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
fmt.Println("Checking container engine...")
|
||||||
|
version, err := r.docker(ctx, "version", "--format", "{{.Server.Version}}")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Docker is required and the daemon must be accessible: %w", err)
|
||||||
|
}
|
||||||
|
r.record.ContainerEngine = "Docker " + strings.TrimSpace(string(version))
|
||||||
|
|
||||||
|
fmt.Println("Building Phase 1 environment...")
|
||||||
|
if err := r.dockerStream(ctx, "build", "-t", imageName, "-f",
|
||||||
|
filepath.Join(root, "environment", "Dockerfile"), root); err != nil {
|
||||||
|
return fmt.Errorf("build environment image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Creating restricted graphical environment...")
|
||||||
|
_, err = r.docker(ctx,
|
||||||
|
"create",
|
||||||
|
"--name", r.containerName,
|
||||||
|
"--init",
|
||||||
|
"--network=none",
|
||||||
|
"--read-only",
|
||||||
|
"--tmpfs=/tmp:rw,nosuid,nodev,size=64m",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges",
|
||||||
|
"--pids-limit=128",
|
||||||
|
"--memory=512m",
|
||||||
|
"--cpus=1",
|
||||||
|
imageName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create environment: %w", err)
|
||||||
|
}
|
||||||
|
r.containerMade = true
|
||||||
|
if _, err := r.docker(ctx, "start", r.containerName); err != nil {
|
||||||
|
return fmt.Errorf("start environment: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.waitFor(ctx, 10*time.Second, func() bool {
|
||||||
|
_, readyErr := r.docker(ctx, "exec", r.containerName, "test", "-f", "/tmp/agentbox-ready")
|
||||||
|
return readyErr == nil
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("wait for graphical environment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Launching graphical mover demo...")
|
||||||
|
if _, err := r.docker(ctx, "exec", "-d", r.containerName, "sh", "-c",
|
||||||
|
"exec /opt/agentbox/mover >/tmp/stdout.log 2>/tmp/stderr.log"); err != nil {
|
||||||
|
return fmt.Errorf("launch demo: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowID string
|
||||||
|
if err := r.waitFor(ctx, 10*time.Second, func() bool {
|
||||||
|
output, searchErr := r.docker(ctx, "exec", r.containerName, "xdotool",
|
||||||
|
"search", "--name", "Agentbox Mover")
|
||||||
|
if searchErr != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
windowID = strings.TrimSpace(strings.Split(string(output), "\n")[0])
|
||||||
|
return windowID != ""
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("wait for demo window: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := r.docker(ctx, "exec", r.containerName, "xdotool",
|
||||||
|
"windowactivate", "--sync", windowID); err != nil {
|
||||||
|
return fmt.Errorf("focus demo window: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforePath := filepath.Join(r.runDir, "screenshots", "before.png")
|
||||||
|
fmt.Println("Capturing screenshot before input...")
|
||||||
|
if err := r.screenshot(ctx, "/tmp/before.png", beforePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
before, err := squareCentroid(beforePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect before screenshot: %w", err)
|
||||||
|
}
|
||||||
|
r.record.Before = &before
|
||||||
|
|
||||||
|
fmt.Println("Sending synthetic mouse input...")
|
||||||
|
if _, err := r.docker(ctx, "exec", r.containerName, "xdotool",
|
||||||
|
"mousemove", "--window", windowID, "100", "100", "mousedown", "1", "mouseup", "1"); err != nil {
|
||||||
|
return fmt.Errorf("send mouse input: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.trace(actionRecord{
|
||||||
|
Timestamp: time.Now().UTC(), Type: "mouse_move", X: 100, Y: 100,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.trace(actionRecord{
|
||||||
|
Timestamp: time.Now().UTC(), Type: "mouse_down", Button: 1,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.trace(actionRecord{
|
||||||
|
Timestamp: time.Now().UTC(), Type: "mouse_up", Button: 1,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Holding RIGHT for one second...")
|
||||||
|
if _, err := r.docker(ctx, "exec", r.containerName, "xdotool", "keydown", "Right"); err != nil {
|
||||||
|
return fmt.Errorf("send key down: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.trace(actionRecord{
|
||||||
|
Timestamp: time.Now().UTC(), Type: "key_down", Key: "RIGHT",
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
}
|
||||||
|
if _, err := r.docker(ctx, "exec", r.containerName, "xdotool", "keyup", "Right"); err != nil {
|
||||||
|
return fmt.Errorf("send key up: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.trace(actionRecord{
|
||||||
|
Timestamp: time.Now().UTC(), Type: "key_up", Key: "RIGHT", Duration: "1s",
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
afterPath := filepath.Join(r.runDir, "screenshots", "after.png")
|
||||||
|
fmt.Println("Capturing screenshot after input...")
|
||||||
|
if err := r.screenshot(ctx, "/tmp/after.png", afterPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
after, err := squareCentroid(afterPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect after screenshot: %w", err)
|
||||||
|
}
|
||||||
|
r.record.After = &after
|
||||||
|
r.record.MovementPixels = after.X - before.X
|
||||||
|
if r.record.MovementPixels < 100 {
|
||||||
|
return fmt.Errorf("visual verification failed: square moved %.1f pixels right, want at least 100", r.record.MovementPixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Verified: square moved %.1f pixels to the right.\n", r.record.MovementPixels)
|
||||||
|
fmt.Printf("Phase 1 passed. Artifacts: %s\n", runDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRoot() (string, error) {
|
||||||
|
current, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get working directory: %w", err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
data, readErr := os.ReadFile(filepath.Join(current, "go.mod"))
|
||||||
|
if readErr == nil && bytes.Contains(data, []byte("module agentbox")) {
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(current)
|
||||||
|
if parent == current {
|
||||||
|
return "", errors.New("run from the agentbox module directory")
|
||||||
|
}
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runID() (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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) waitFor(ctx context.Context, timeout time.Duration, check func() bool) error {
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
if check() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return errors.New("timed out")
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) screenshot(ctx context.Context, containerPath, hostPath string) error {
|
||||||
|
if _, err := r.docker(ctx, "exec", r.containerName, "scrot", "-o", containerPath); err != nil {
|
||||||
|
return fmt.Errorf("capture screenshot: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.extract(ctx, containerPath, hostPath); err != nil {
|
||||||
|
return fmt.Errorf("extract screenshot: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) extract(ctx context.Context, containerPath, hostPath string) error {
|
||||||
|
data, err := r.docker(ctx, "exec", r.containerName, "cat", containerPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(hostPath, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", hostPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func squareCentroid(path string) (point, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return point{}, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
img, err := png.Decode(file)
|
||||||
|
if err != nil {
|
||||||
|
return point{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sumX, sumY, count uint64
|
||||||
|
bounds := img.Bounds()
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
red, green, blue, _ := img.At(x, y).RGBA()
|
||||||
|
// Match the demo square's #00ff66 color with enough tolerance for
|
||||||
|
// image conversion while rejecting the black background.
|
||||||
|
if green > 0xc000 && red < 0x4000 && blue < 0x8000 {
|
||||||
|
sumX += uint64(x)
|
||||||
|
sumY += uint64(y)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count < 1000 {
|
||||||
|
return point{}, fmt.Errorf("found only %d green square pixels", count)
|
||||||
|
}
|
||||||
|
return point{
|
||||||
|
X: float64(sumX) / float64(count),
|
||||||
|
Y: float64(sumY) / float64(count),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) trace(action actionRecord) error {
|
||||||
|
encoded, err := json.Marshal(action)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode action: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := r.actions.Write(append(encoded, '\n')); err != nil {
|
||||||
|
return fmt.Errorf("write action trace: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) cleanup() error {
|
||||||
|
if r.actions != nil {
|
||||||
|
_ = r.actions.Close()
|
||||||
|
}
|
||||||
|
if !r.containerMade {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Stopping environment...")
|
||||||
|
stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for _, name := range []string{"stdout.log", "stderr.log"} {
|
||||||
|
_ = r.extract(stopCtx, "/tmp/"+name, filepath.Join(r.runDir, name))
|
||||||
|
}
|
||||||
|
_, stopErr := r.docker(stopCtx, "stop", "--time=3", r.containerName)
|
||||||
|
_, removeErr := r.docker(stopCtx, "rm", "-f", r.containerName)
|
||||||
|
if stopErr != nil {
|
||||||
|
return fmt.Errorf("stop environment: %w", stopErr)
|
||||||
|
}
|
||||||
|
if removeErr != nil {
|
||||||
|
return fmt.Errorf("remove environment: %w", removeErr)
|
||||||
|
}
|
||||||
|
fmt.Println("Environment stopped cleanly.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) writeRecord() error {
|
||||||
|
data, err := json.MarshalIndent(r.record, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode run record: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if err := os.WriteFile(filepath.Join(r.runDir, "run.json"), data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write run record: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) docker(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
|
command := exec.CommandContext(ctx, "docker", args...)
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return output, fmt.Errorf("docker %s: %w: %s", args[0], err, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) dockerStream(ctx context.Context, args ...string) error {
|
||||||
|
command := exec.CommandContext(ctx, "docker", args...)
|
||||||
|
command.Stdout = os.Stdout
|
||||||
|
command.Stderr = os.Stderr
|
||||||
|
if err := command.Run(); err != nil {
|
||||||
|
return fmt.Errorf("docker %s: %w", strconv.Quote(strings.Join(args, " ")), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package phase1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSquareCentroid(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "screenshot.png")
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 200, 100))
|
||||||
|
green := color.RGBA{R: 0, G: 255, B: 102, A: 255}
|
||||||
|
for y := 20; y < 60; y++ {
|
||||||
|
for x := 80; x < 120; x++ {
|
||||||
|
img.Set(x, y, green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writePNG(t, path, img)
|
||||||
|
|
||||||
|
got, err := squareCentroid(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("squareCentroid() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.X != 99.5 || got.Y != 39.5 {
|
||||||
|
t.Fatalf("squareCentroid() = (%.1f, %.1f), want (99.5, 39.5)", got.X, got.Y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSquareCentroidRejectsMissingSquare(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "empty.png")
|
||||||
|
writePNG(t, path, image.NewRGBA(image.Rect(0, 0, 200, 100)))
|
||||||
|
|
||||||
|
if _, err := squareCentroid(path); err == nil {
|
||||||
|
t.Fatal("squareCentroid() error = nil, want missing-square error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePNG(t *testing.T, path string, img image.Image) {
|
||||||
|
t.Helper()
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := png.Encode(file, img); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agentbox/internal/agent"
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
"agentbox/internal/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config wires replaceable environment, agent, and trace implementations into
|
||||||
|
// one run. The runtime itself has no Docker, X11, or model-provider knowledge.
|
||||||
|
type Config struct {
|
||||||
|
Task string
|
||||||
|
Command environment.Command
|
||||||
|
Controller agent.Agent
|
||||||
|
Environment environment.Environment
|
||||||
|
TraceStore *trace.Store
|
||||||
|
MaxSteps int
|
||||||
|
Output io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, config Config) (runErr error) {
|
||||||
|
if config.MaxSteps <= 0 {
|
||||||
|
config.MaxSteps = 20
|
||||||
|
}
|
||||||
|
if config.Output == nil {
|
||||||
|
config.Output = io.Discard
|
||||||
|
}
|
||||||
|
if config.Controller == nil || config.Environment == nil || config.TraceStore == nil {
|
||||||
|
return errors.New("runtime requires agent, environment, and trace")
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
runErr = finalizeRun(config, runErr)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := config.Environment.Start(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := config.Environment.Launch(ctx, config.Command); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(config.Output, "Agent loop started.")
|
||||||
|
|
||||||
|
var stepHistory []agent.Step
|
||||||
|
var actionHistory []environment.InputAction
|
||||||
|
runStartedAt := time.Now()
|
||||||
|
for stepNumber := 1; stepNumber <= config.MaxSteps; stepNumber++ {
|
||||||
|
screenshot, err := config.Environment.Screenshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
screenshotPath, err := config.TraceStore.SaveScreenshot(stepNumber, screenshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applicationLogs, err := config.Environment.Logs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
observedAt := time.Now().UTC()
|
||||||
|
previousActions := append([]environment.InputAction(nil), actionHistory...)
|
||||||
|
observation := agent.Observation{
|
||||||
|
Screenshot: screenshot,
|
||||||
|
Timestamp: observedAt,
|
||||||
|
Logs: applicationLogs,
|
||||||
|
PreviousActions: previousActions,
|
||||||
|
}
|
||||||
|
fmt.Fprintf(config.Output, "[%s] screenshot captured\n", elapsed(runStartedAt))
|
||||||
|
|
||||||
|
decision, err := config.Controller.NextAction(
|
||||||
|
ctx,
|
||||||
|
config.Task,
|
||||||
|
stepHistory,
|
||||||
|
observation,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("agent next action: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(
|
||||||
|
config.Output,
|
||||||
|
"[%s] agent: %s\n",
|
||||||
|
elapsed(runStartedAt),
|
||||||
|
decision.Reason,
|
||||||
|
)
|
||||||
|
if err := config.TraceStore.Record(trace.StepRecord{
|
||||||
|
Step: stepNumber,
|
||||||
|
Timestamp: observedAt,
|
||||||
|
Observation: trace.ObservationRecord{
|
||||||
|
Screenshot: screenshotPath,
|
||||||
|
Logs: applicationLogs,
|
||||||
|
PreviousActions: previousActions,
|
||||||
|
},
|
||||||
|
Agent: trace.AgentRecord{Message: decision.Reason},
|
||||||
|
Action: decision.Action,
|
||||||
|
Done: decision.Done,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stepHistory = append(stepHistory, agent.Step{
|
||||||
|
Number: stepNumber,
|
||||||
|
Timestamp: observedAt,
|
||||||
|
ScreenshotPath: screenshotPath,
|
||||||
|
Message: decision.Reason,
|
||||||
|
Action: decision.Action,
|
||||||
|
})
|
||||||
|
|
||||||
|
if decision.Done {
|
||||||
|
fmt.Fprintln(config.Output, "Task complete.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if decision.Action == nil {
|
||||||
|
return errors.New("agent returned neither action nor completion")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(
|
||||||
|
config.Output,
|
||||||
|
"[%s] action: %s%s\n",
|
||||||
|
elapsed(runStartedAt),
|
||||||
|
decision.Action.Type,
|
||||||
|
actionDetail(*decision.Action),
|
||||||
|
)
|
||||||
|
if err := config.Environment.SendInput(ctx, *decision.Action); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actionHistory = append(actionHistory, *decision.Action)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("agent exceeded maximum of %d steps", config.MaxSteps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalizeRun uses a fresh timeout because the caller's context may already be
|
||||||
|
// canceled. Preserving logs and removing the environment must still be tried.
|
||||||
|
func finalizeRun(
|
||||||
|
config Config,
|
||||||
|
runErr error,
|
||||||
|
) error {
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if logs, err := config.Environment.Logs(cleanupCtx); err == nil {
|
||||||
|
if logErr := config.TraceStore.WriteLogs(logs); runErr == nil && logErr != nil {
|
||||||
|
runErr = logErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stop is idempotent, so always call it. Start may have created a container
|
||||||
|
// before returning an error while waiting for its graphical services.
|
||||||
|
if stopErr := config.Environment.Stop(cleanupCtx); runErr == nil && stopErr != nil {
|
||||||
|
runErr = stopErr
|
||||||
|
}
|
||||||
|
if finishErr := config.TraceStore.Finish(runErr); runErr == nil && finishErr != nil {
|
||||||
|
runErr = finishErr
|
||||||
|
}
|
||||||
|
return runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func elapsed(start time.Time) string {
|
||||||
|
duration := time.Since(start).Round(time.Second)
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%02d:%02d",
|
||||||
|
int(duration.Minutes()),
|
||||||
|
int(duration.Seconds())%60,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionDetail(action environment.InputAction) string {
|
||||||
|
switch action.Type {
|
||||||
|
case environment.KeyDown, environment.KeyUp:
|
||||||
|
return " " + action.Key
|
||||||
|
case environment.MouseMove:
|
||||||
|
return fmt.Sprintf(" %d,%d", action.X, action.Y)
|
||||||
|
case environment.MouseDown, environment.MouseUp:
|
||||||
|
return fmt.Sprintf(" %d", action.Button)
|
||||||
|
case environment.Wait:
|
||||||
|
return fmt.Sprintf(" %dms", action.DurationMS)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agentbox/internal/agent"
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
"agentbox/internal/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
type failingStartEnvironment struct {
|
||||||
|
stopCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fake *failingStartEnvironment) Start(context.Context) error {
|
||||||
|
return errors.New("startup failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fake *failingStartEnvironment) Launch(context.Context, environment.Command) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fake *failingStartEnvironment) Screenshot(context.Context) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fake *failingStartEnvironment) SendInput(context.Context, environment.InputAction) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fake *failingStartEnvironment) Logs(context.Context) ([]environment.LogEntry, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fake *failingStartEnvironment) Stop(context.Context) error {
|
||||||
|
fake.stopCalled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStopsEnvironmentWhenStartFails(t *testing.T) {
|
||||||
|
traceStore, err := trace.New(t.TempDir(), "task", "application", "test-agent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fakeEnvironment := &failingStartEnvironment{}
|
||||||
|
|
||||||
|
err = Run(context.Background(), Config{
|
||||||
|
Task: "task",
|
||||||
|
Controller: &agent.Deterministic{},
|
||||||
|
Environment: fakeEnvironment,
|
||||||
|
TraceStore: traceStore,
|
||||||
|
Output: io.Discard,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want startup error")
|
||||||
|
}
|
||||||
|
if !fakeEnvironment.stopCalled {
|
||||||
|
t.Fatal("Run() did not stop environment after startup failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Package trace stores durable run records. steps.jsonl contains complete
|
||||||
|
// observations and decisions, actions.jsonl is an action-only view, and PNG
|
||||||
|
// screenshots remain separate files referenced by relative paths.
|
||||||
|
package trace
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package trace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
func List(projectRoot string) ([]Run, error) {
|
||||||
|
runsDirectory := filepath.Join(projectRoot, ".agentbox", "runs")
|
||||||
|
directoryEntries, err := os.ReadDir(runsDirectory)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var runs []Run
|
||||||
|
for _, entry := range directoryEntries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
run, err := Read(projectRoot, entry.Name())
|
||||||
|
// Old or malformed trace directories are intentionally hidden rather
|
||||||
|
// than making the entire run listing fail.
|
||||||
|
if err == nil && run.SchemaVersion == SchemaVersion {
|
||||||
|
runs = append(runs, run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(runs, func(left, right int) bool {
|
||||||
|
return runs[left].StartedAt.After(runs[right].StartedAt)
|
||||||
|
})
|
||||||
|
return runs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Read(projectRoot, runID string) (Run, error) {
|
||||||
|
if err := validateRunID(runID); err != nil {
|
||||||
|
return Run{}, err
|
||||||
|
}
|
||||||
|
runPath := filepath.Join(
|
||||||
|
projectRoot,
|
||||||
|
".agentbox",
|
||||||
|
"runs",
|
||||||
|
runID,
|
||||||
|
"run.json",
|
||||||
|
)
|
||||||
|
data, err := os.ReadFile(runPath)
|
||||||
|
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(projectRoot, runID string) ([]StepRecord, error) {
|
||||||
|
if err := validateRunID(runID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stepsPath := filepath.Join(
|
||||||
|
projectRoot,
|
||||||
|
".agentbox",
|
||||||
|
"runs",
|
||||||
|
runID,
|
||||||
|
"steps.jsonl",
|
||||||
|
)
|
||||||
|
stepsFile, err := os.Open(stepsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer stepsFile.Close()
|
||||||
|
|
||||||
|
var steps []StepRecord
|
||||||
|
scanner := bufio.NewScanner(stepsFile)
|
||||||
|
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 validateRunID(runID string) error {
|
||||||
|
// Run IDs become path components, so reject separators and traversal.
|
||||||
|
if filepath.Base(runID) != runID {
|
||||||
|
return errors.New("invalid run ID")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package trace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store writes one run's metadata and append-only event streams.
|
||||||
|
type Store struct {
|
||||||
|
runDirectory string
|
||||||
|
run Run
|
||||||
|
stepsFile *os.File
|
||||||
|
actionsFile *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(projectRoot, task, application, agentName string) (*Store, error) {
|
||||||
|
runID, err := newRunID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
runDirectory := filepath.Join(projectRoot, ".agentbox", "runs", runID)
|
||||||
|
screenshotDirectory := filepath.Join(runDirectory, "screenshots")
|
||||||
|
if err := os.MkdirAll(screenshotDirectory, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create run directory: %w", err)
|
||||||
|
}
|
||||||
|
stepsFile, err := os.Create(filepath.Join(runDirectory, "steps.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create step trace: %w", err)
|
||||||
|
}
|
||||||
|
actionsFile, err := os.Create(filepath.Join(runDirectory, "actions.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
_ = stepsFile.Close()
|
||||||
|
return nil, fmt.Errorf("create action trace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &Store{
|
||||||
|
runDirectory: runDirectory,
|
||||||
|
stepsFile: stepsFile,
|
||||||
|
actionsFile: actionsFile,
|
||||||
|
run: Run{
|
||||||
|
SchemaVersion: SchemaVersion,
|
||||||
|
ID: runID,
|
||||||
|
Task: task,
|
||||||
|
Application: application,
|
||||||
|
Agent: agentName,
|
||||||
|
Status: "running",
|
||||||
|
StartedAt: time.Now().UTC(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := store.writeRunSummary(); err != nil {
|
||||||
|
_ = stepsFile.Close()
|
||||||
|
_ = actionsFile.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ID() string {
|
||||||
|
return store.run.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Directory() string {
|
||||||
|
return store.runDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) SaveScreenshot(stepNumber int, screenshot []byte) (string, error) {
|
||||||
|
relativePath := filepath.Join(
|
||||||
|
"screenshots",
|
||||||
|
fmt.Sprintf("%04d.png", stepNumber),
|
||||||
|
)
|
||||||
|
absolutePath := filepath.Join(store.runDirectory, relativePath)
|
||||||
|
if err := os.WriteFile(absolutePath, screenshot, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write screenshot: %w", err)
|
||||||
|
}
|
||||||
|
// Trace paths always use slash separators so traces are portable.
|
||||||
|
return filepath.ToSlash(relativePath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Record(step StepRecord) error {
|
||||||
|
if err := appendJSONLine(store.stepsFile, step); err != nil {
|
||||||
|
return fmt.Errorf("record step: %w", err)
|
||||||
|
}
|
||||||
|
if step.Action != nil {
|
||||||
|
action := actionRecord{
|
||||||
|
Step: step.Step,
|
||||||
|
Timestamp: step.Timestamp,
|
||||||
|
Action: *step.Action,
|
||||||
|
}
|
||||||
|
if err := appendJSONLine(store.actionsFile, action); err != nil {
|
||||||
|
return fmt.Errorf("record action: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.run.StepCount = step.Step
|
||||||
|
return store.writeRunSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) WriteLogs(logEntries []environment.LogEntry) error {
|
||||||
|
var standardOutput, standardError string
|
||||||
|
for _, entry := range logEntries {
|
||||||
|
switch entry.Stream {
|
||||||
|
case "stdout":
|
||||||
|
standardOutput = entry.Message
|
||||||
|
case "stderr":
|
||||||
|
standardError = entry.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(store.runDirectory, "stdout.log"),
|
||||||
|
[]byte(standardOutput),
|
||||||
|
0o644,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(
|
||||||
|
filepath.Join(store.runDirectory, "stderr.log"),
|
||||||
|
[]byte(standardError),
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Finish(runErr error) error {
|
||||||
|
store.closeEventFiles()
|
||||||
|
store.run.FinishedAt = time.Now().UTC()
|
||||||
|
if runErr != nil {
|
||||||
|
store.run.Status = "failed"
|
||||||
|
store.run.Error = runErr.Error()
|
||||||
|
} else {
|
||||||
|
store.run.Status = "complete"
|
||||||
|
}
|
||||||
|
return store.writeRunSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) closeEventFiles() {
|
||||||
|
if store.stepsFile != nil {
|
||||||
|
_ = store.stepsFile.Close()
|
||||||
|
store.stepsFile = nil
|
||||||
|
}
|
||||||
|
if store.actionsFile != nil {
|
||||||
|
_ = store.actionsFile.Close()
|
||||||
|
store.actionsFile = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync each JSONL record so an interrupted run retains its latest complete
|
||||||
|
// decision. Performance is secondary to debuggability in this local spike.
|
||||||
|
func appendJSONLine(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 (store *Store) writeRunSummary() error {
|
||||||
|
data, err := json.MarshalIndent(store.run, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runPath := filepath.Join(store.runDirectory, "run.json")
|
||||||
|
return os.WriteFile(runPath, append(data, '\n'), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunID() (string, error) {
|
||||||
|
randomSuffix := make([]byte, 3)
|
||||||
|
if _, err := rand.Read(randomSuffix); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
timestamp := time.Now().UTC().Format("20060102T150405")
|
||||||
|
return timestamp + "-" + hex.EncodeToString(randomSuffix), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package trace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agentbox/internal/environment"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SchemaVersion changes only when existing trace readers need new logic.
|
||||||
|
const SchemaVersion = "1"
|
||||||
|
|
||||||
|
// Run is the summary stored in run.json.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepRecord is one append-only line in steps.jsonl.
|
||||||
|
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 actionRecord struct {
|
||||||
|
Step int `json:"step"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Action environment.InputAction `json:"action"`
|
||||||
|
}
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||||
|
image=agentbox-runtime:local
|
||||||
|
output="$root/examples/mover/mover"
|
||||||
|
temporary="$output.tmp"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$temporary"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
docker build -q -t "$image" -f "$root/environment/Dockerfile" "$root" >/dev/null
|
||||||
|
docker run --rm --network=none --entrypoint cat "$image" /opt/agentbox/mover >"$temporary"
|
||||||
|
chmod 0755 "$temporary"
|
||||||
|
mv "$temporary" "$output"
|
||||||
Reference in New Issue
Block a user