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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 0 additions & 35 deletions autoresearch/results.tsv

This file was deleted.

215 changes: 215 additions & 0 deletions cli/cmd/deploy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package cmd

import (
"context"
"fmt"
"os"
"strings"
"time"

"gopkg.in/yaml.v3"

"github.com/spawn08/chronos/engine/graph"
"github.com/spawn08/chronos/engine/tool/builtins"
"github.com/spawn08/chronos/sandbox"
"github.com/spawn08/chronos/sdk/agent"
"github.com/spawn08/chronos/sdk/team"
)

// DeployConfig is the YAML config for deploying agents/teams in a sandbox.
type DeployConfig struct {
Name string `yaml:"name"`
Sandbox DeploySandboxConfig `yaml:"sandbox"`
Agents []agent.AgentConfig `yaml:"agents"`
Teams []agent.TeamConfig `yaml:"teams,omitempty"`

Defaults *agent.AgentConfig `yaml:"defaults,omitempty"`
}

// DeploySandboxConfig defines the sandbox environment for deployment.
type DeploySandboxConfig struct {
Backend string `yaml:"backend"` // process, container, k8s
WorkDir string `yaml:"work_dir,omitempty"`
Image string `yaml:"image,omitempty"`
Network string `yaml:"network,omitempty"`
Timeout string `yaml:"timeout,omitempty"` // e.g. "5m", "30s"
}

func runDeploy() error {
args := os.Args[2:]
if len(args) < 2 {
return fmt.Errorf("usage: chronos deploy <config.yaml> <message>")
}
configPath := args[0]
message := strings.Join(args[1:], " ")

data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read deploy config: %w", err)
}

var dc DeployConfig
if err := yaml.Unmarshal(data, &dc); err != nil {
return fmt.Errorf("parse deploy config: %w", err)
}

fmt.Println("╔═══════════════════════════════════════════════════╗")
fmt.Println("║ Chronos Deploy ║")
fmt.Println("╚═══════════════════════════════════════════════════╝")
fmt.Printf(" Deployment: %s\n", dc.Name)
fmt.Printf(" Sandbox: %s\n", dc.Sandbox.Backend)
fmt.Printf(" Agents: %d\n", len(dc.Agents))
fmt.Printf(" Teams: %d\n", len(dc.Teams))
fmt.Printf(" Message: %s\n\n", message)

ctx := context.Background()

// Set up sandbox
timeout := 5 * time.Minute
if dc.Sandbox.Timeout != "" {
if t, err := time.ParseDuration(dc.Sandbox.Timeout); err == nil {
timeout = t
}
}

sb, err := sandbox.NewFromConfig(sandbox.Config{
Backend: sandbox.ParseBackend(dc.Sandbox.Backend),
WorkDir: dc.Sandbox.WorkDir,
Image: dc.Sandbox.Image,
Network: dc.Sandbox.Network,
})
if err != nil {
return fmt.Errorf("create sandbox: %w", err)
}
defer sb.Close()

fmt.Printf("━━━ Sandbox initialized (%s, timeout=%s) ━━━\n\n", dc.Sandbox.Backend, timeout)

// Build the FileConfig from the deploy config
fc := &agent.FileConfig{
Agents: dc.Agents,
Teams: dc.Teams,
Defaults: dc.Defaults,
}

// Apply defaults
if fc.Defaults != nil {
for i := range fc.Agents {
applyDeployDefaults(&fc.Agents[i], fc.Defaults)
}
}

// Build all agents with sandbox-aware tools
agents, err := agent.BuildAll(ctx, fc)
if err != nil {
return fmt.Errorf("build agents: %w", err)
}

// Register sandbox-backed tools on agents that have tool capabilities
for _, a := range agents {
registerSandboxTools(a, sb, timeout)
}

fmt.Printf(" Built %d agents\n", len(agents))

// If there are teams, run the first (or specified) team
if len(dc.Teams) > 0 {
tc := dc.Teams[0]
strategy, err := parseStrategy(tc.Strategy)
if err != nil {
return err
}

t := team.New(tc.ID, tc.Name, strategy)
for _, agentID := range tc.Agents {
a, ok := agents[agentID]
if !ok {
return fmt.Errorf("team %q references unknown agent %q", tc.ID, agentID)
}
t.AddAgent(a)
}
if tc.Coordinator != "" {
coord, ok := agents[tc.Coordinator]
if !ok {
return fmt.Errorf("team %q references unknown coordinator %q", tc.ID, tc.Coordinator)
}
t.SetCoordinator(coord)
}
if tc.MaxConcurrency > 0 {
t.SetMaxConcurrency(tc.MaxConcurrency)
}
if tc.MaxIterations > 0 {
t.SetMaxIterations(tc.MaxIterations)
}
if tc.ErrorStrategy != "" {
es, esErr := parseErrorStrategy(tc.ErrorStrategy)
if esErr != nil {
return esErr
}
t.SetErrorStrategy(es)
}

fmt.Printf("\n━━━ Running team: %s (%s strategy) ━━━\n", tc.Name, tc.Strategy)
result, err := t.Run(ctx, graph.State{"message": message})
if err != nil {
return fmt.Errorf("team run: %w", err)
}

if resp, ok := result["response"]; ok {
fmt.Printf("\n━━━ Result ━━━\n%v\n", resp)
} else {
for k, v := range result {
if strings.HasPrefix(k, "_") {
continue
}
fmt.Printf(" %s: %v\n", k, v)
}
}
fmt.Printf("\n [%d inter-agent messages exchanged]\n", len(t.MessageHistory()))
} else if len(agents) > 0 {
// No team — run the first agent directly
var firstAgent *agent.Agent
for _, a := range agents {
firstAgent = a
break
}
fmt.Printf("\n━━━ Running agent: %s ━━━\n", firstAgent.Name)
resp, err := firstAgent.Chat(ctx, message)
if err != nil {
return fmt.Errorf("agent chat: %w", err)
}
fmt.Printf("\n━━━ Result ━━━\n%s\n", resp.Content)
}

fmt.Println("\n✓ Deployment complete.")
return nil
}

// registerSandboxTools adds sandbox-backed shell and file tools to an agent.
func registerSandboxTools(a *agent.Agent, sb sandbox.Sandbox, timeout time.Duration) {
sandboxShell := builtins.NewSandboxShellTool(sb, timeout)
if _, exists := a.Tools.Get("shell"); !exists {
a.Tools.Register(sandboxShell)
}
}

func applyDeployDefaults(cfg, defaults *agent.AgentConfig) {
if cfg.Model.Provider == "" {
cfg.Model.Provider = defaults.Model.Provider
}
if cfg.Model.Model == "" {
cfg.Model.Model = defaults.Model.Model
}
if cfg.Model.APIKey == "" {
cfg.Model.APIKey = defaults.Model.APIKey
}
if cfg.Model.BaseURL == "" {
cfg.Model.BaseURL = defaults.Model.BaseURL
}
if cfg.Storage.Backend == "" {
cfg.Storage.Backend = defaults.Storage.Backend
}
if cfg.System == "" {
cfg.System = defaults.System
}
}
9 changes: 8 additions & 1 deletion cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ func Execute() error {
return runEvalCmd()
case "config":
return runConfig()
case "deploy":
return runDeploy()
case "monitor":
return runMonitor()
case "version":
Expand Down Expand Up @@ -85,6 +87,7 @@ Commands:
team list List teams defined in config
team run <id> <message> Run a multi-agent team on a task
team show <id> Show team configuration details
deploy <config.yaml> <msg> Deploy agents/team from YAML and run in sandbox
sessions Session management (list, resume, export)
memory Memory management (list, forget, clear)
db Database operations (init, status)
Expand Down Expand Up @@ -546,8 +549,12 @@ func parseStrategy(s string) (team.Strategy, error) {
return team.StrategyRouter, nil
case "coordinator":
return team.StrategyCoordinator, nil
case "swarm":
return team.StrategySwarm, nil
case "hierarchy":
return team.StrategyHierarchy, nil
default:
return "", fmt.Errorf("unknown strategy %q (supported: sequential, parallel, router, coordinator)", s)
return "", fmt.Errorf("unknown strategy %q (supported: sequential, parallel, router, coordinator, swarm, hierarchy)", s)
}
}

Expand Down
32 changes: 17 additions & 15 deletions engine/graph/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,10 @@ func (r *Runner) execute(ctx context.Context, rs *RunState) (*RunState, error) {
rs.Status = RunStatusPaused
rs.SeqNum++
r.emit(StreamEvent{Type: "interrupt", NodeID: node.ID, State: rs.State})
if err := r.checkpoint(ctx, rs); err != nil {
return rs, fmt.Errorf("checkpoint on interrupt: %w", err)
if r.store != nil {
if err := r.checkpoint(ctx, rs); err != nil {
return rs, fmt.Errorf("checkpoint on interrupt: %w", err)
}
}
if graphSpan != nil {
_ = r.tracer.EndSpan(ctx, graphSpan, rs.State, "paused at interrupt node "+node.ID)
Expand Down Expand Up @@ -270,21 +272,21 @@ func (r *Runner) execute(ctx context.Context, rs *RunState) (*RunState, error) {
_ = r.tracer.EndSpan(ctx, nodeSpan, rs.State, "")
}

// Checkpoint after each node
if err := r.checkpoint(ctx, rs); err != nil {
return rs, fmt.Errorf("checkpoint: %w", err)
// Checkpoint after each node (skip if no storage is configured)
if r.store != nil {
if err := r.checkpoint(ctx, rs); err != nil {
return rs, fmt.Errorf("checkpoint: %w", err)
}
_ = r.store.AppendEvent(ctx, &storage.Event{
ID: fmt.Sprintf("evt_%s_%d", rs.RunID, rs.SeqNum),
SessionID: rs.SessionID,
SeqNum: rs.SeqNum,
Type: "node_executed",
Payload: map[string]any{"node": node.ID, "state": rs.State},
CreatedAt: time.Now(),
})
}

// Append event to ledger
_ = r.store.AppendEvent(ctx, &storage.Event{
ID: fmt.Sprintf("evt_%s_%d", rs.RunID, rs.SeqNum),
SessionID: rs.SessionID,
SeqNum: rs.SeqNum,
Type: "node_executed",
Payload: map[string]any{"node": node.ID, "state": rs.State},
CreatedAt: time.Now(),
})

// Find next node
next := r.findNext(rs.CurrentNode, rs.State)
if next == EndNode || next == "" {
Expand Down
51 changes: 51 additions & 0 deletions engine/tool/builtins/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,21 @@ import (
"time"

"github.com/spawn08/chronos/engine/tool"
"github.com/spawn08/chronos/sandbox"
)

// NewAutoShellTool creates a shell tool that auto-approves all commands.
// Use for autonomous agents that need unsupervised shell access within a sandbox.
// allowedCommands restricts which commands can run; an empty list means all are allowed.
// timeout controls max execution time (0 = 30s default).
func NewAutoShellTool(allowedCommands []string, timeout time.Duration) *tool.Definition {
def := NewShellTool(allowedCommands, timeout)
def.Name = "shell"
def.Permission = tool.PermAllow
def.Description = "Execute a shell command and return stdout/stderr. Auto-approved for autonomous agents."
return def
}

// NewShellTool creates a tool that executes shell commands.
// allowedCommands restricts which commands can run; an empty list means all are allowed.
// timeout controls max execution time (0 = 30s default).
Expand Down Expand Up @@ -78,3 +91,41 @@ func NewShellTool(allowedCommands []string, timeout time.Duration) *tool.Definit
},
}
}

// NewSandboxShellTool creates a shell tool that executes commands inside a Sandbox.
// Commands run in isolation — the agent cannot escape the sandbox boundary.
func NewSandboxShellTool(sb sandbox.Sandbox, timeout time.Duration) *tool.Definition {
if timeout <= 0 {
timeout = 30 * time.Second
}
return &tool.Definition{
Name: "shell",
Description: "Execute a shell command inside the sandbox environment. Returns stdout, stderr, and exit code.",
Permission: tool.PermAllow,
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
"description": "The shell command to execute",
},
},
"required": []string{"command"},
},
Handler: func(ctx context.Context, args map[string]any) (any, error) {
command, ok := args["command"].(string)
if !ok || command == "" {
return nil, fmt.Errorf("sandbox_shell: 'command' argument is required")
}
result, err := sb.Execute(ctx, "sh", []string{"-c", command}, timeout)
if err != nil {
return nil, fmt.Errorf("sandbox_shell: %w", err)
}
return map[string]any{
"stdout": result.Stdout,
"stderr": result.Stderr,
"exit_code": result.ExitCode,
}, nil
},
}
}
Loading
Loading