Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
183 lines
4.8 KiB
Go
183 lines
4.8 KiB
Go
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 attached.")
|
|
|
|
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 ""
|
|
}
|
|
}
|