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
58 changes: 55 additions & 3 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/docker/docker-agent/blob/main/agent-schema.json",
"title": "Docker Agent Configuration",
"description": "Configuration schema for Docker Agent v8",
"description": "Configuration schema for Docker Agent v9",
"type": "object",
"properties": {
"version": {
Expand All @@ -17,7 +17,8 @@
"5",
"6",
"7",
"8"
"8",
"9"
],
"examples": [
"0",
Expand All @@ -28,7 +29,8 @@
"5",
"6",
"7",
"8"
"8",
"9"
]
},
"providers": {
Expand Down Expand Up @@ -530,6 +532,10 @@
"type": "string",
"description": "Instructions for the agent"
},
"harness": {
"$ref": "#/definitions/HarnessConfig",
"description": "External coding harness to run this agent with instead of a docker-agent model provider"
},
"code_mode_tools": {
"type": "boolean",
"description": "Enable Code Mode for tools"
Expand Down Expand Up @@ -749,6 +755,52 @@
},
"additionalProperties": false
},
"HarnessConfig": {
"type": "object",
"description": "Configuration for running an agent through an external coding-agent CLI via github.com/rumpl/harness",
"properties": {
"type": {
"type": "string",
"description": "External coding harness provider to use",
"enum": [
"claude-code",
"codex",
"pi",
"opencode"
],
"examples": [
"claude-code",
"codex"
]
},
"model": {
"type": "string",
"description": "Optional model name passed to the external harness. When omitted, the external CLI uses its own default model."
},
"effort": {
"type": "string",
"description": "Claude Code effort level forwarded as --effort",
"enum": [
"low",
"medium",
"high",
"max"
]
},
"agent": {
"type": "string",
"description": "opencode agent name forwarded as --agent"
},
"thinking": {
"type": "boolean",
"description": "Enable opencode thinking output"
}
},
"required": [
"type"
],
"additionalProperties": false
},
"FallbackConfig": {
"type": "object",
"description": "Configuration for fallback model behavior when the primary model fails",
Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ remote MCP endpoints.
| [`writer.yaml`](writer.yaml) | Story writing supervisor with specialized sub-agents. |
| [`finance.yaml`](finance.yaml) | Financial research orchestrating analysts. |
| [`background_agents.yaml`](background_agents.yaml) | Parallel research delegated to background sub-agents. |
| [`coding_harnesses.yaml`](coding_harnesses.yaml) | Orchestrator delegating coding tasks to external harness-backed sub-agents. |
| [`coding_harness_background_agents.yaml`](coding_harness_background_agents.yaml) | Orchestrator running external coding harnesses concurrently via background agents. |
| [`dev-team.yaml`](dev-team.yaml) | Product-manager-led team (designer + engineer) with shared memory. |
| [`multi-code.yaml`](multi-code.yaml) | Tech-lead routing tasks to a frontend and a Go expert. |
| [`coder.yaml`](coder.yaml) | Coding agent with planner, implementer, and librarian sub-agents. |
Expand Down
41 changes: 41 additions & 0 deletions examples/coding_harness_background_agents.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
agents:
root:
model: anthropic/claude-sonnet-4-5
description: Orchestrator that runs external coding harnesses in parallel
instruction: |
You coordinate coding work across external coding harnesses.

When the user asks for work that can be split into independent tasks:
1. Start multiple specialists with `run_background_agent` before waiting
for any one task to finish.
2. Use `list_background_agents` to monitor running work.
3. Use `view_background_agent` to inspect each task's output.
4. Synthesize the results into a concise final answer with changed files,
tests run, and any follow-up work.

Use claude-coder for broad refactors and multi-file changes. Use
codex-coder for focused implementation, test generation, or isolated fixes.
sub_agents:
- claude-coder
- codex-coder
toolsets:
- type: think
- type: background_agents

claude-coder:
description: Claude Code running as an external coding harness
instruction: |
You are a senior software engineer. Complete the assigned coding task in
the repository. Keep changes focused, run relevant tests when possible,
and summarize files changed, validation performed, and any blockers.
harness:
type: claude-code

codex-coder:
description: Codex running as an external coding harness
instruction: |
You are a pragmatic software engineer. Complete the assigned coding task
in the repository. Prefer small, well-tested changes and report the exact
validation commands you ran.
harness:
type: codex
20 changes: 20 additions & 0 deletions examples/coding_harnesses.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
agents:
root:
model: anthropic/claude-sonnet-4-5
description: Orchestrator
instruction: Route coding tasks to the appropriate specialist.
sub_agents:
- claude-coder
- codex-coder

claude-coder:
description: Claude Code for complex refactors
instruction: You are a senior software engineer.
harness:
type: claude-code

codex-coder:
description: Codex for code generation
instruction: You are a software engineer.
harness:
type: codex
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ require (
github.com/openai/openai-go/v3 v3.36.0
github.com/pb33f/libopenapi v0.36.4
github.com/rivo/uniseg v0.4.7
github.com/rumpl/harness v0.0.0-20260519225334-1d956be4fff1
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rumpl/harness v0.0.0-20260519225334-1d956be4fff1 h1:yyMEKlqFxa0ujeH4hrBD0rYj5TRD/h98zspUSbFaCl8=
github.com/rumpl/harness v0.0.0-20260519225334-1d956be4fff1/go.mod h1:D0KcsF5BBYJDBeIQYXMNZpGYFgGMeQ4uOKKX81SwUv0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
Expand Down
13 changes: 13 additions & 0 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type Agent struct {
addPromptFiles []string
tools []tools.Tool
commands types.Commands
harness *latest.HarnessConfig
hooks *latest.HooksConfig
cache *cache.Cache

Expand Down Expand Up @@ -160,6 +161,9 @@ func (a *Agent) Model(ctx context.Context) provider.Provider {
selected = (*overrides)[rand.Intn(len(*overrides))]
poolSize = len(*overrides)
} else {
if len(a.models) == 0 {
return nil
}
selected = a.models[rand.Intn(len(a.models))]
poolSize = len(a.models)
}
Expand Down Expand Up @@ -268,6 +272,15 @@ func (a *Agent) Commands() types.Commands {
return a.commands
}

// Harness returns the external coding harness configuration for this agent.
func (a *Agent) Harness() *latest.HarnessConfig {
return a.harness
}

func (a *Agent) HasHarness() bool {
return a.harness != nil
}

// Hooks returns the hooks configuration for this agent.
func (a *Agent) Hooks() *latest.HooksConfig {
return a.hooks
Expand Down
11 changes: 11 additions & 0 deletions pkg/agent/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,17 @@ func WithCommands(commands types.Commands) Opt {
}
}

func WithHarness(harness *latest.HarnessConfig) Opt {
return func(a *Agent) {
if harness == nil {
a.harness = nil
return
}
cfg := *harness
a.harness = &cfg
}
}

func WithLoadTimeWarnings(warnings []string) Opt {
return func(a *Agent) {
for _, w := range warnings {
Expand Down
71 changes: 71 additions & 0 deletions pkg/codingharness/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package codingharness

import (
"errors"
"fmt"
"strings"

baseharness "github.com/rumpl/harness"
"github.com/rumpl/harness/claudecode"
"github.com/rumpl/harness/codex"
"github.com/rumpl/harness/opencode"
"github.com/rumpl/harness/pi"

"github.com/docker/docker-agent/pkg/config/latest"
)

const (
TypeClaudeCode = "claude-code"
TypeCodex = "codex"
TypePi = "pi"
TypeOpenCode = "opencode"
)

func NewProvider(cfg *latest.HarnessConfig) (baseharness.Provider, error) {
if cfg == nil {
return nil, errors.New("harness config is nil")
}

switch cfg.Type {
case TypeClaudeCode:
return newClaudeCodeProvider(cfg), nil
case TypeCodex:
return codex.New(cfg.Model), nil
case TypePi:
return pi.New(cfg.Model), nil
case TypeOpenCode:
return newOpenCodeProvider(cfg), nil
default:
return nil, fmt.Errorf("unsupported harness type %q", cfg.Type)
}
}

func Label(cfg *latest.HarnessConfig) string {
if cfg == nil {
return ""
}
model := strings.TrimSpace(cfg.Model)
if model == "" {
return cfg.Type
}
return cfg.Type + "/" + model
}

func newClaudeCodeProvider(cfg *latest.HarnessConfig) baseharness.Provider {
var opts []claudecode.Option
if cfg.Effort != "" {
opts = append(opts, claudecode.WithEffort(claudecode.Effort(cfg.Effort)))
}
return claudecode.New(cfg.Model, opts...)
}

func newOpenCodeProvider(cfg *latest.HarnessConfig) baseharness.Provider {
var opts []opencode.Option
if cfg.Agent != "" {
opts = append(opts, opencode.WithAgent(cfg.Agent))
}
if cfg.Thinking {
opts = append(opts, opencode.WithThinking())
}
return opencode.New(cfg.Model, opts...)
}
32 changes: 32 additions & 0 deletions pkg/codingharness/provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package codingharness

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/config/latest"
)

func TestNewProviderOmitsModelFlagWhenModelEmpty(t *testing.T) {
p, err := NewProvider(&latest.HarnessConfig{Type: TypeCodex})
require.NoError(t, err)

cmd := p.PrintCommand("do it")
require.Contains(t, cmd, "codex exec --json")
require.NotContains(t, cmd, " -m ")
}

func TestNewProviderUsesConfiguredModel(t *testing.T) {
p, err := NewProvider(&latest.HarnessConfig{Type: TypeClaudeCode, Model: "claude-sonnet-4-5", Effort: "high"})
require.NoError(t, err)

cmd := p.PrintCommand("do it")
require.Contains(t, cmd, "--model 'claude-sonnet-4-5'")
require.Contains(t, cmd, "--effort high")
}

func TestLabel(t *testing.T) {
require.Equal(t, "codex", Label(&latest.HarnessConfig{Type: TypeCodex}))
require.Equal(t, "codex/gpt-5", Label(&latest.HarnessConfig{Type: TypeCodex, Model: "gpt-5"}))
}
43 changes: 43 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,49 @@ func TestApplyModelOverrides(t *testing.T) {
}
}

func TestValidateConfig_HarnessAgentsSkipModelValidation(t *testing.T) {
t.Parallel()

cfg := &latest.Config{
Agents: []latest.AgentConfig{
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"coder"}},
{Name: "coder", Harness: &latest.HarnessConfig{Type: "codex"}},
},
}

require.NoError(t, validateConfig(cfg))
_, exists := cfg.Models[""]
assert.False(t, exists)
}

func TestValidateConfig_HarnessValidation(t *testing.T) {
t.Parallel()

tests := []struct {
name string
harness *latest.HarnessConfig
wantErr string
}{
{name: "valid claude code", harness: &latest.HarnessConfig{Type: "claude-code", Effort: "high"}},
{name: "missing type", harness: &latest.HarnessConfig{}, wantErr: "harness.type is required"},
{name: "bad type", harness: &latest.HarnessConfig{Type: "vim"}, wantErr: "unsupported harness.type"},
{name: "bad effort", harness: &latest.HarnessConfig{Type: "claude-code", Effort: "ultra"}, wantErr: "harness.effort must be one of"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &latest.Config{Agents: []latest.AgentConfig{{Name: "root", Harness: tt.harness}}}
err := cfg.Validate()
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
})
}
}

func TestValidateConfig_ExternalSubAgentReferences(t *testing.T) {
t.Parallel()

Expand Down
4 changes: 3 additions & 1 deletion pkg/config/examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ func TestParseExamples(t *testing.T) {
require.NotEmpty(t, cfg.Agents.First().Description, "Description should not be empty in %s", file)

for _, agent := range cfg.Agents {
require.NotEmpty(t, agent.Model)
if agent.Harness == nil {
require.NotEmpty(t, agent.Model)
}
require.NotEmpty(t, agent.Instruction, "Instruction should not be empty in %s", file)
}

Expand Down
Loading
Loading