From 557948b0ba1e602a02c98ab5b6b8d679a76df7c3 Mon Sep 17 00:00:00 2001 From: John McBride Date: Sat, 28 Feb 2026 16:32:04 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20support=20[[agents]]=20and?= =?UTF-8?q?=20"replicas"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John McBride --- jcard.toml | 12 +- pkg/config/config.go | 244 +++++++++++++++++++------ pkg/config/config_test.go | 366 ++++++++++++++++++++++++++++++++++---- pkg/config/marshal.go | 24 ++- 4 files changed, 547 insertions(+), 99 deletions(-) diff --git a/jcard.toml b/jcard.toml index e95978d..6146ad2 100644 --- a/jcard.toml +++ b/jcard.toml @@ -1,6 +1,12 @@ -mixtape = "opencode-mixtape:latest" +mixtape = "test:latest" -[agent] +[resources] +cpus = 2 +memory = "8GiB" + +[[agents]] +type = "native" harness = "opencode" -prompt = "Hello world!" +prompt = "Use cowsay to print Hello world!" extra_packages = [ "cowsay" ] +replicas = 10 diff --git a/pkg/config/config.go b/pkg/config/config.go index f8b6085..873f129 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -60,8 +60,9 @@ type JcardConfig struct { // Secrets injected into the sandbox at runtime via stereosd. Secrets map[string]string `toml:"secrets"` - // Agent runtime configuration (passed to agentd). - Agent AgentConfig `toml:"agent"` + // Agents defines the agent harnesses to run inside this sandbox. + // Each entry is an independent agent managed by agentd. + Agents []AgentConfig `toml:"agents"` } // ResourcesConfig describes the VM resource allocation. @@ -106,6 +107,10 @@ type AgentConfig struct { // "native" runs directly on the host in a tmux session. Type AgentType `toml:"type,omitempty"` + // Name is a unique identifier for this agent. If omitted, a name is + // auto-generated from the harness name (e.g. "claude-code", "claude-code-1"). + Name string `toml:"name"` + // Harness is the agent harness to use: "claude-code", "opencode", // "gemini-cli", or "custom". Harness string `toml:"harness"` @@ -143,6 +148,12 @@ type AgentConfig struct { // into /nix/store at agent launch time. Only used for sandboxed agents. ExtraPackages []string `toml:"extra_packages,omitempty"` + // Replicas is the number of identical agents to launch from this + // spec. Defaults to 1. When > 1, each replica gets a unique name + // suffixed with its index (e.g. "reviewer-0", "reviewer-1"). + // Useful for launching swarms of agents performing the same task. + Replicas int `toml:"replicas"` + // Env are environment variables set only for the agent process. Env map[string]string `toml:"env"` } @@ -193,10 +204,12 @@ func DefaultJcard() *JcardConfig { Network: NetworkConfig{ Mode: "nat", }, - Agent: AgentConfig{ - Harness: "claude-code", - Workdir: "/workspace", - Restart: "no", + Agents: []AgentConfig{ + { + Harness: "claude-code", + Workdir: "/workspace", + Restart: "no", + }, }, } return cfg @@ -229,30 +242,129 @@ func applyDefaults(cfg *JcardConfig) { } } - if cfg.Agent.Type == "" { - cfg.Agent.Type = AgentTypeSandboxed + // Apply per-agent defaults. + for i := range cfg.Agents { + a := &cfg.Agents[i] + if a.Type == "" { + a.Type = AgentTypeSandboxed + } + if a.Replicas <= 0 { + a.Replicas = 1 + } + if a.Restart == "" { + a.Restart = "no" + } + if a.GracePeriod == "" { + a.GracePeriod = "30s" + } + if a.Workdir == "" { + if len(cfg.Shared) > 0 { + a.Workdir = cfg.Shared[0].Guest + } else { + a.Workdir = "/workspace" + } + } + if a.Env == nil { + a.Env = make(map[string]string) + } + } + + // Expand replicas before name assignment: a single [[agents]] entry + // with replicas=5 becomes 5 individual agent entries. + cfg.Agents = expandReplicas(cfg.Agents) + + // Auto-generate agent names for agents without explicit names. + assignAgentNames(cfg.Agents) + + if cfg.Secrets == nil { + cfg.Secrets = make(map[string]string) } +} - if cfg.Agent.Restart == "" { - cfg.Agent.Restart = "no" +// expandReplicas expands agent entries with Replicas > 1 into individual +// agent entries. Each replica is a copy of the original with a unique +// name suffix. For replicas=1, the entry is left unchanged. +// +// Naming rules: +// - replicas=1, name="rev" -> "rev" (unchanged) +// - replicas=3, name="rev" -> "rev-0", "rev-1", "rev-2" +// - replicas=3, name="" -> name left empty (assignAgentNames handles it later) +// but since there are now 3 unnamed entries with the same harness, +// assignAgentNames will produce "claude-code-0", "claude-code-1", "claude-code-2" +func expandReplicas(agents []AgentConfig) []AgentConfig { + // Fast path: if all agents have replicas=1, return as-is. + needsExpansion := false + total := 0 + for i := range agents { + if agents[i].Replicas > 1 { + needsExpansion = true + } + total += agents[i].Replicas } - if cfg.Agent.GracePeriod == "" { - cfg.Agent.GracePeriod = "30s" + if !needsExpansion { + return agents } - if cfg.Agent.Workdir == "" { - // Default to first shared mount, or /workspace - if len(cfg.Shared) > 0 { - cfg.Agent.Workdir = cfg.Shared[0].Guest - } else { - cfg.Agent.Workdir = "/workspace" + + expanded := make([]AgentConfig, 0, total) + for _, a := range agents { + if a.Replicas <= 1 { + expanded = append(expanded, a) + continue + } + + baseName := a.Name + for j := 0; j < a.Replicas; j++ { + replica := a + replica.Replicas = 1 + if baseName != "" { + replica.Name = fmt.Sprintf("%s-%d", baseName, j) + } + // If baseName is empty, leave Name empty — assignAgentNames + // will handle it and produce unique names from the harness. + // Session is also left empty so it defaults to the final name. + replica.Session = "" + // Deep-copy the env map so replicas don't share a reference. + if a.Env != nil { + replica.Env = make(map[string]string, len(a.Env)) + for k, v := range a.Env { + replica.Env[k] = v + } + } + expanded = append(expanded, replica) } } + return expanded +} - if cfg.Secrets == nil { - cfg.Secrets = make(map[string]string) +// assignAgentNames fills in Name for agents that don't have one set. +// The first agent with a given harness gets the harness name (e.g. "claude-code"). +// Subsequent agents with the same harness get "-1", "-2", etc. +func assignAgentNames(agents []AgentConfig) { + // Count how many times each harness appears (for unnamed agents). + harnessCount := make(map[string]int) + for i := range agents { + if agents[i].Name == "" { + harnessCount[agents[i].Harness]++ + } } - if cfg.Agent.Env == nil { - cfg.Agent.Env = make(map[string]string) + + // Track how many of each harness we've assigned so far. + harnessIdx := make(map[string]int) + for i := range agents { + if agents[i].Name != "" { + continue + } + h := agents[i].Harness + idx := harnessIdx[h] + harnessIdx[h]++ + + if harnessCount[h] == 1 { + // Only one unnamed agent with this harness — use harness name directly. + agents[i].Name = h + } else { + // Multiple unnamed agents — suffix with index. + agents[i].Name = fmt.Sprintf("%s-%d", h, idx) + } } } @@ -264,16 +376,16 @@ func expandPaths(cfg *JcardConfig, baseDir string) { cfg.Shared[i].Host = expandPath(cfg.Shared[i].Host, baseDir) } - // Expand prompt_file relative to jcard.toml - if cfg.Agent.PromptFile != "" { - cfg.Agent.PromptFile = expandPath(cfg.Agent.PromptFile, baseDir) + // Expand per-agent paths + for i := range cfg.Agents { + if cfg.Agents[i].PromptFile != "" { + cfg.Agents[i].PromptFile = expandPath(cfg.Agents[i].PromptFile, baseDir) + } + cfg.Agents[i].Env = expandEnvMap(cfg.Agents[i].Env) } // Expand environment variable references in secrets cfg.Secrets = expandEnvMap(cfg.Secrets) - - // Expand environment variable references in agent env - cfg.Agent.Env = expandEnvMap(cfg.Agent.Env) } // validate checks that required fields are present and values are sane. @@ -299,45 +411,61 @@ func validate(cfg *JcardConfig) error { } } - // Validate agent type. - switch cfg.Agent.Type { - case AgentTypeSandboxed, AgentTypeNative: - // valid - default: - return fmt.Errorf("agent.type must be \"sandboxed\" or \"native\", got %q", cfg.Agent.Type) + // Validate each agent. + validHarnesses := map[string]bool{ + "claude-code": true, + "opencode": true, + "gemini-cli": true, + "custom": true, + } + validRestart := map[string]bool{"no": true, "on-failure": true, "always": true} + validAgentTypes := map[string]AgentType{ + "sandboxed": AgentTypeSandboxed, + "native": AgentTypeNative, } + namesSeen := make(map[string]bool, len(cfg.Agents)) - if cfg.Agent.Harness != "" { - validHarnesses := map[string]bool{ - "claude-code": true, - "opencode": true, - "gemini-cli": true, - "custom": true, + for i, a := range cfg.Agents { + // Validate agent type. + if _, ok := validAgentTypes[string(a.Type)]; !ok { + return fmt.Errorf("agents[%d].type must be \"sandboxed\" or \"native\", got %q", i, a.Type) } - if !validHarnesses[cfg.Agent.Harness] { - return fmt.Errorf("agent.harness must be \"claude-code\", \"opencode\", \"gemini-cli\", or \"custom\", got %q", cfg.Agent.Harness) + + // Validate unique names. + if namesSeen[a.Name] { + return fmt.Errorf("agents[%d]: duplicate agent name %q", i, a.Name) } - } + namesSeen[a.Name] = true - validRestart := map[string]bool{"no": true, "on-failure": true, "always": true} - if !validRestart[cfg.Agent.Restart] { - return fmt.Errorf("agent.restart must be \"no\", \"on-failure\", or \"always\", got %q", cfg.Agent.Restart) - } + if a.Harness != "" { + if !validHarnesses[a.Harness] { + return fmt.Errorf("agents[%d].harness must be \"claude-code\", \"opencode\", \"gemini-cli\", or \"custom\", got %q", i, a.Harness) + } + } - if cfg.Agent.MaxRestarts < 0 { - return fmt.Errorf("agent.max_restarts must be >= 0, got %d", cfg.Agent.MaxRestarts) - } + if !validRestart[a.Restart] { + return fmt.Errorf("agents[%d].restart must be \"no\", \"on-failure\", or \"always\", got %q", i, a.Restart) + } - // Validate extra_packages entries are non-empty strings. - for i, pkg := range cfg.Agent.ExtraPackages { - if strings.TrimSpace(pkg) == "" { - return fmt.Errorf("agent.extra_packages[%d] is empty", i) + if a.MaxRestarts < 0 { + return fmt.Errorf("agents[%d].max_restarts must be >= 0, got %d", i, a.MaxRestarts) } - } - // extra_packages is only valid for sandboxed agents. - if cfg.Agent.Type != AgentTypeSandboxed && len(cfg.Agent.ExtraPackages) > 0 { - return fmt.Errorf("agent.extra_packages is only supported for type=\"sandboxed\"") + if a.Replicas < 1 { + return fmt.Errorf("agents[%d].replicas must be >= 1, got %d", i, a.Replicas) + } + + // Validate extra_packages entries are non-empty strings. + for j, pkg := range a.ExtraPackages { + if strings.TrimSpace(pkg) == "" { + return fmt.Errorf("agents[%d].extra_packages[%d] is empty", i, j) + } + } + + // extra_packages is only valid for sandboxed agents. + if a.Type != AgentTypeSandboxed && len(a.ExtraPackages) > 0 { + return fmt.Errorf("agents[%d].extra_packages is only supported for type=\"sandboxed\"", i) + } } return nil diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1066e0c..672c3c4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -48,20 +48,52 @@ mixtape = "base" Expect(cfg.Network.Mode).To(Equal("nat")) }) + It("should have an empty agents list", func() { + Expect(cfg.Agents).To(BeEmpty()) + }) + }) + + Context("with a single agent config", func() { + var cfg *JcardConfig + + BeforeEach(func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[[agents]] +harness = "claude-code" +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + var err error + cfg, err = Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should have one agent", func() { + Expect(cfg.Agents).To(HaveLen(1)) + }) + It("should apply default agent restart policy", func() { - Expect(cfg.Agent.Restart).To(Equal("no")) + Expect(cfg.Agents[0].Restart).To(Equal("no")) }) It("should apply default agent grace period", func() { - Expect(cfg.Agent.GracePeriod).To(Equal("30s")) + Expect(cfg.Agents[0].GracePeriod).To(Equal("30s")) }) It("should apply default agent workdir", func() { - Expect(cfg.Agent.Workdir).To(Equal("/workspace")) + Expect(cfg.Agents[0].Workdir).To(Equal("/workspace")) + }) + + It("should auto-generate agent name from harness", func() { + Expect(cfg.Agents[0].Name).To(Equal("claude-code")) }) It("should apply default agent type as sandboxed", func() { - Expect(cfg.Agent.Type).To(Equal(AgentTypeSandboxed)) + Expect(cfg.Agents[0].Type).To(Equal(AgentTypeSandboxed)) }) }) @@ -108,7 +140,8 @@ readonly = true [secrets] MY_SECRET = "secret-value" -[agent] +[[agents]] +name = "reviewer" harness = "claude-code" prompt_file = "./prompt.md" workdir = "/workspace" @@ -118,7 +151,7 @@ timeout = "2h" grace_period = "60s" session = "my-session" -[agent.env] +[agents.env] FOO = "bar" ` cfgPath := filepath.Join(dir, "jcard.toml") @@ -164,21 +197,261 @@ FOO = "bar" }) It("should parse agent configuration", func() { - Expect(cfg.Agent.Harness).To(Equal("claude-code")) - Expect(cfg.Agent.PromptFile).To(Equal(filepath.Join(dir, "prompt.md"))) - Expect(cfg.Agent.Restart).To(Equal("on-failure")) - Expect(cfg.Agent.MaxRestarts).To(Equal(5)) - Expect(cfg.Agent.Timeout).To(Equal("2h")) - Expect(cfg.Agent.GracePeriod).To(Equal("60s")) - Expect(cfg.Agent.Session).To(Equal("my-session")) + Expect(cfg.Agents).To(HaveLen(1)) + a := cfg.Agents[0] + Expect(a.Name).To(Equal("reviewer")) + Expect(a.Harness).To(Equal("claude-code")) + Expect(a.PromptFile).To(Equal(filepath.Join(dir, "prompt.md"))) + Expect(a.Restart).To(Equal("on-failure")) + Expect(a.MaxRestarts).To(Equal(5)) + Expect(a.Timeout).To(Equal("2h")) + Expect(a.GracePeriod).To(Equal("60s")) + Expect(a.Session).To(Equal("my-session")) }) It("should parse agent env", func() { - Expect(cfg.Agent.Env).To(HaveKeyWithValue("FOO", "bar")) + Expect(cfg.Agents[0].Env).To(HaveKeyWithValue("FOO", "bar")) + }) + }) + + Context("with multiple agents", func() { + var cfg *JcardConfig + + BeforeEach(func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[[agents]] +name = "reviewer" +harness = "claude-code" +prompt = "review the code" + +[[agents]] +name = "coder" +harness = "opencode" +prompt = "implement the feature" + +[[agents]] +harness = "gemini-cli" +prompt = "check for security issues" +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + var err error + cfg, err = Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should parse all agents", func() { + Expect(cfg.Agents).To(HaveLen(3)) + }) + + It("should preserve explicit names", func() { + Expect(cfg.Agents[0].Name).To(Equal("reviewer")) + Expect(cfg.Agents[1].Name).To(Equal("coder")) + }) + + It("should auto-generate name for unnamed agent", func() { + Expect(cfg.Agents[2].Name).To(Equal("gemini-cli")) + }) + + It("should parse each agent's harness", func() { + Expect(cfg.Agents[0].Harness).To(Equal("claude-code")) + Expect(cfg.Agents[1].Harness).To(Equal("opencode")) + Expect(cfg.Agents[2].Harness).To(Equal("gemini-cli")) + }) + }) + + Context("with duplicate unnamed harnesses", func() { + var cfg *JcardConfig + + BeforeEach(func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[[agents]] +harness = "claude-code" +prompt = "task one" + +[[agents]] +harness = "claude-code" +prompt = "task two" +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + var err error + cfg, err = Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should generate unique names", func() { + Expect(cfg.Agents[0].Name).To(Equal("claude-code-0")) + Expect(cfg.Agents[1].Name).To(Equal("claude-code-1")) }) }) }) + Describe("Replicas", func() { + It("should default replicas to 1", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[[agents]] +harness = "claude-code" +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agents).To(HaveLen(1)) + Expect(cfg.Agents[0].Replicas).To(Equal(1)) + Expect(cfg.Agents[0].Name).To(Equal("claude-code")) + }) + + It("should expand replicas=1 without suffix", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[[agents]] +name = "reviewer" +harness = "claude-code" +replicas = 1 +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agents).To(HaveLen(1)) + Expect(cfg.Agents[0].Name).To(Equal("reviewer")) + }) + + It("should expand named replicas with index suffix", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[[agents]] +name = "reviewer" +harness = "claude-code" +prompt = "review code" +replicas = 3 +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agents).To(HaveLen(3)) + Expect(cfg.Agents[0].Name).To(Equal("reviewer-0")) + Expect(cfg.Agents[1].Name).To(Equal("reviewer-1")) + Expect(cfg.Agents[2].Name).To(Equal("reviewer-2")) + // Each replica should have the same harness and prompt. + for _, a := range cfg.Agents { + Expect(a.Harness).To(Equal("claude-code")) + Expect(a.Prompt).To(Equal("review code")) + Expect(a.Replicas).To(Equal(1)) + } + }) + + It("should expand unnamed replicas and auto-generate names", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[[agents]] +harness = "claude-code" +replicas = 3 +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agents).To(HaveLen(3)) + Expect(cfg.Agents[0].Name).To(Equal("claude-code-0")) + Expect(cfg.Agents[1].Name).To(Equal("claude-code-1")) + Expect(cfg.Agents[2].Name).To(Equal("claude-code-2")) + }) + + It("should handle mixed replicas and single agents", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[[agents]] +name = "lead" +harness = "claude-code" + +[[agents]] +name = "worker" +harness = "opencode" +replicas = 3 +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agents).To(HaveLen(4)) + Expect(cfg.Agents[0].Name).To(Equal("lead")) + Expect(cfg.Agents[1].Name).To(Equal("worker-0")) + Expect(cfg.Agents[2].Name).To(Equal("worker-1")) + Expect(cfg.Agents[3].Name).To(Equal("worker-2")) + }) + + It("should deep-copy env maps across replicas", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[[agents]] +name = "worker" +harness = "claude-code" +replicas = 2 + +[agents.env] +KEY = "value" +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agents).To(HaveLen(2)) + // Verify both have the env var. + Expect(cfg.Agents[0].Env).To(HaveKeyWithValue("KEY", "value")) + Expect(cfg.Agents[1].Env).To(HaveKeyWithValue("KEY", "value")) + }) + + It("should support large replica counts", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[[agents]] +name = "swarm" +harness = "claude-code" +replicas = 500 +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agents).To(HaveLen(500)) + Expect(cfg.Agents[0].Name).To(Equal("swarm-0")) + Expect(cfg.Agents[499].Name).To(Equal("swarm-499")) + }) + }) + Describe("Validation", func() { DescribeTable("should reject invalid configs", @@ -199,13 +472,14 @@ mode = "invalid" Entry("invalid harness", ` mixtape = "base" -[agent] +[[agents]] harness = "invalid-harness" `), Entry("invalid restart policy", ` mixtape = "base" -[agent] +[[agents]] +harness = "claude-code" restart = "invalid" `), Entry("invalid port forward (host port 0)", ` @@ -216,23 +490,37 @@ mode = "nat" forwards = [ { host = 0, guest = 80, proto = "tcp" }, ] +`), + Entry("duplicate agent names", ` +mixtape = "base" + +[[agents]] +name = "same" +harness = "claude-code" + +[[agents]] +name = "same" +harness = "opencode" `), Entry("invalid agent type", ` mixtape = "base" -[agent] +[[agents]] +harness = "claude-code" type = "docker" `), Entry("extra_packages with empty entry", ` mixtape = "base" -[agent] +[[agents]] +harness = "claude-code" extra_packages = ["ripgrep", "", "fd"] `), Entry("extra_packages on native agent", ` mixtape = "base" -[agent] +[[agents]] +harness = "claude-code" type = "native" extra_packages = ["ripgrep"] `), @@ -334,13 +622,16 @@ mixtape = "base" [[shared]] host = "./" guest = "/code" + +[[agents]] +harness = "claude-code" ` cfgPath := filepath.Join(dir, "jcard.toml") Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) cfg, err := Load(cfgPath) Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Agent.Workdir).To(Equal("/code")) + Expect(cfg.Agents[0].Workdir).To(Equal("/code")) }) }) @@ -350,7 +641,8 @@ guest = "/code" tomlContent := ` mixtape = "base" -[agent] +[[agents]] +harness = "claude-code" type = "sandboxed" ` cfgPath := filepath.Join(dir, "jcard.toml") @@ -358,7 +650,7 @@ type = "sandboxed" cfg, err := Load(cfgPath) Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Agent.Type).To(Equal(AgentTypeSandboxed)) + Expect(cfg.Agents[0].Type).To(Equal(AgentTypeSandboxed)) }) It("should parse type=native", func() { @@ -366,7 +658,8 @@ type = "sandboxed" tomlContent := ` mixtape = "base" -[agent] +[[agents]] +harness = "claude-code" type = "native" ` cfgPath := filepath.Join(dir, "jcard.toml") @@ -374,7 +667,7 @@ type = "native" cfg, err := Load(cfgPath) Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Agent.Type).To(Equal(AgentTypeNative)) + Expect(cfg.Agents[0].Type).To(Equal(AgentTypeNative)) }) }) @@ -384,7 +677,8 @@ type = "native" tomlContent := ` mixtape = "base" -[agent] +[[agents]] +harness = "claude-code" type = "sandboxed" extra_packages = ["ripgrep", "fd", "python311"] ` @@ -393,7 +687,7 @@ extra_packages = ["ripgrep", "fd", "python311"] cfg, err := Load(cfgPath) Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Agent.ExtraPackages).To(Equal([]string{"ripgrep", "fd", "python311"})) + Expect(cfg.Agents[0].ExtraPackages).To(Equal([]string{"ripgrep", "fd", "python311"})) }) It("should accept empty extra_packages list", func() { @@ -401,7 +695,8 @@ extra_packages = ["ripgrep", "fd", "python311"] tomlContent := ` mixtape = "base" -[agent] +[[agents]] +harness = "claude-code" extra_packages = [] ` cfgPath := filepath.Join(dir, "jcard.toml") @@ -409,7 +704,7 @@ extra_packages = [] cfg, err := Load(cfgPath) Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Agent.ExtraPackages).To(BeEmpty()) + Expect(cfg.Agents[0].ExtraPackages).To(BeEmpty()) }) It("should accept sandboxed agent without extra_packages", func() { @@ -417,7 +712,8 @@ extra_packages = [] tomlContent := ` mixtape = "base" -[agent] +[[agents]] +harness = "claude-code" type = "sandboxed" ` cfgPath := filepath.Join(dir, "jcard.toml") @@ -425,15 +721,17 @@ type = "sandboxed" cfg, err := Load(cfgPath) Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Agent.ExtraPackages).To(BeNil()) + Expect(cfg.Agents[0].ExtraPackages).To(BeNil()) }) It("should round-trip extra_packages through Marshal", func() { cfg := &JcardConfig{ Mixtape: "base", - Agent: AgentConfig{ - Harness: "claude-code", - ExtraPackages: []string{"ripgrep", "fd"}, + Agents: []AgentConfig{ + { + Harness: "claude-code", + ExtraPackages: []string{"ripgrep", "fd"}, + }, }, } data, err := Marshal(cfg) @@ -445,7 +743,7 @@ type = "sandboxed" loaded, err := Load(cfgPath) Expect(err).NotTo(HaveOccurred()) - Expect(loaded.Agent.ExtraPackages).To(Equal([]string{"ripgrep", "fd"})) + Expect(loaded.Agents[0].ExtraPackages).To(Equal([]string{"ripgrep", "fd"})) }) }) }) diff --git a/pkg/config/marshal.go b/pkg/config/marshal.go index d79ec0a..87d5eaf 100644 --- a/pkg/config/marshal.go +++ b/pkg/config/marshal.go @@ -82,8 +82,13 @@ mode = "nat" # ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" # Agent runtime configuration. -# Defines what agent harness to run and how agentd manages it. -[agent] +# Multiple agents can run concurrently inside a single sandbox. +# Each [[agents]] entry defines an independent agent managed by agentd. + +[[agents]] +# Unique name for this agent (optional, auto-generated from harness if omitted). +# name = "reviewer" + # Agent execution mode: # "sandboxed" -> runs in a gVisor (runsc) container with /nix/store (default) # "native" -> runs directly in a tmux session as the agent user @@ -128,8 +133,19 @@ restart = "no" # at agent launch time. Only used when type = "sandboxed". # extra_packages = ["ripgrep", "fd", "python311"] -# Environment variables set *only* for the agent process. -# [agent.env] +# Number of identical agent replicas to launch from this spec. +# Each replica gets a unique name suffixed with its index (e.g. "reviewer-0", +# "reviewer-1"). Useful for launching swarms of agents performing the same task. +# replicas = 1 + +# Environment variables set *only* for this agent process. +# [agents.env] # MY_VAR = "my_value" + +# To add more agents, add another [[agents]] block: +# [[agents]] +# name = "coder" +# harness = "opencode" +# prompt = "implement the feature" ` }