Skip to content

Commit d3e10cf

Browse files
committed
feat: add harness model selection
1 parent abd6b0a commit d3e10cf

10 files changed

Lines changed: 220 additions & 16 deletions

File tree

cmd/dun/main.go

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ var respondFn = dun.Respond
3232
var installRepo = dun.InstallRepo
3333
var callHarnessFn = callHarnessImpl
3434
var callHarnessStreamingFn = callHarnessStreamingImpl
35+
var harnessModel string
36+
var harnessModelOverrides map[string]string
3537

3638
func main() {
3739
code := run(os.Args[1:], os.Stdout, os.Stderr)
@@ -99,6 +101,8 @@ REVIEW MODE:
99101
--principles Path to principles document (default docs/helix/01-frame/principles.md)
100102
--harnesses Comma-separated list of review harnesses (default: codex,claude,gemini)
101103
--synth-harness Harness to synthesize final review (default: first harness)
104+
--model Model override for selected harness(es)
105+
--models Per-harness model overrides (e.g., codex:o3,claude:sonnet)
102106
--automation Mode: manual, plan, auto, yolo (default: auto)
103107
--dry-run Print prompt without calling harnesses
104108
--verbose Print individual harness reviews
@@ -121,6 +125,8 @@ LOOP MODE:
121125
122126
Options:
123127
--harness Agent to use: codex, claude, gemini, opencode (default: from config)
128+
--model Model override for selected harness(es)
129+
--models Per-harness model overrides (e.g., codex:o3,claude:sonnet)
124130
--automation Mode: manual, plan, auto, yolo (default: auto)
125131
--max-iterations Safety limit (default: 100)
126132
--dry-run Show prompt without calling agent
@@ -501,6 +507,8 @@ func runLoop(args []string, stdout io.Writer, stderr io.Writer) int {
501507
fs.SetOutput(stderr)
502508
configPath := fs.String("config", explicitConfig, "path to config file")
503509
harness := fs.String("harness", "", "agent harness (codex|claude|gemini|opencode); default from config")
510+
model := fs.String("model", opts.AgentModel, "model override for selected harness(es)")
511+
models := fs.String("models", "", "per-harness model overrides (e.g., codex:o3,claude:sonnet)")
504512
automation := fs.String("automation", opts.AutomationMode, "automation mode (manual|plan|auto|yolo)")
505513
maxIterations := fs.Int("max-iterations", 100, "maximum iterations before stopping")
506514
dryRun := fs.Bool("dry-run", false, "print prompt without calling harness")
@@ -529,6 +537,30 @@ func runLoop(args []string, stdout io.Writer, stderr io.Writer) int {
529537
}
530538
}
531539

540+
modelOverrides := make(map[string]string)
541+
for harnessName, modelName := range opts.AgentModels {
542+
if modelName == "" {
543+
continue
544+
}
545+
modelOverrides[harnessName] = modelName
546+
}
547+
if *models != "" {
548+
parsed, parseErr := parseHarnessModelOverrides(*models)
549+
if parseErr != nil {
550+
fmt.Fprintf(stderr, "dun loop failed: invalid models: %v\n", parseErr)
551+
return dun.ExitUsageError
552+
}
553+
for harnessName, modelName := range parsed {
554+
modelOverrides[harnessName] = modelName
555+
}
556+
}
557+
harnessModel = strings.TrimSpace(*model)
558+
if len(modelOverrides) == 0 {
559+
harnessModelOverrides = nil
560+
} else {
561+
harnessModelOverrides = modelOverrides
562+
}
563+
532564
// Parse quorum configuration if specified
533565
var quorumCfg dun.QuorumConfig
534566
if *quorumFlag != "" || *harnessesFlag != "" {
@@ -737,6 +769,40 @@ func callHarnessStreaming(harness, prompt, automation string, stdout, stderr io.
737769
return callHarnessStreamingFn(harness, prompt, automation, stdout, stderr)
738770
}
739771

772+
func resolveHarnessModel(harness string) string {
773+
if len(harnessModelOverrides) > 0 {
774+
if model, ok := harnessModelOverrides[harness]; ok && model != "" {
775+
return model
776+
}
777+
}
778+
return harnessModel
779+
}
780+
781+
func parseHarnessModelOverrides(raw string) (map[string]string, error) {
782+
if raw == "" {
783+
return nil, nil
784+
}
785+
overrides := make(map[string]string)
786+
parts := strings.Split(raw, ",")
787+
for _, part := range parts {
788+
item := strings.TrimSpace(part)
789+
if item == "" {
790+
continue
791+
}
792+
fields := strings.SplitN(item, ":", 2)
793+
if len(fields) != 2 {
794+
return nil, fmt.Errorf("invalid model override %q (expected harness:model)", item)
795+
}
796+
harness := strings.TrimSpace(fields[0])
797+
model := strings.TrimSpace(fields[1])
798+
if harness == "" || model == "" {
799+
return nil, fmt.Errorf("invalid model override %q (expected harness:model)", item)
800+
}
801+
overrides[harness] = model
802+
}
803+
return overrides, nil
804+
}
805+
740806
func callHarnessImpl(harnessName, prompt, automation string) (string, error) {
741807
// Convert automation string to AutomationMode
742808
var mode dun.AutomationMode
@@ -757,7 +823,8 @@ func callHarnessImpl(harnessName, prompt, automation string) (string, error) {
757823
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
758824
defer cancel()
759825

760-
result, err := dun.ExecuteHarness(ctx, harnessName, prompt, mode, ".")
826+
model := resolveHarnessModel(harnessName)
827+
result, err := dun.ExecuteHarness(ctx, harnessName, prompt, mode, ".", model)
761828
if err != nil {
762829
return "", err
763830
}
@@ -784,7 +851,8 @@ func callHarnessStreamingImpl(harnessName, prompt, automation string, stdout, st
784851
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
785852
defer cancel()
786853

787-
result, err := dun.ExecuteHarnessWithOutput(ctx, harnessName, prompt, mode, ".", stdout, stderr)
854+
model := resolveHarnessModel(harnessName)
855+
result, err := dun.ExecuteHarnessWithOutput(ctx, harnessName, prompt, mode, ".", model, stdout, stderr)
788856
if err != nil {
789857
return "", err
790858
}

cmd/dun/main_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,9 @@ func TestRunHelpIncludesLoop(t *testing.T) {
13171317
if !strings.Contains(output, "--harness") {
13181318
t.Fatalf("help should document harness option")
13191319
}
1320+
if !strings.Contains(output, "--model") {
1321+
t.Fatalf("help should document model option")
1322+
}
13201323
if !strings.Contains(output, "--max-iterations") {
13211324
t.Fatalf("help should document max-iterations option")
13221325
}

cmd/dun/review.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ func runReview(args []string, stdout io.Writer, stderr io.Writer) int {
4343
principlesPath := fs.String("principles", "docs/helix/01-frame/principles.md", "path to principles document")
4444
harnessesFlag := fs.String("harnesses", "codex,claude,gemini", "comma-separated list of review harnesses")
4545
synthHarness := fs.String("synth-harness", "", "harness used to synthesize final review (default: first harness)")
46+
model := fs.String("model", opts.AgentModel, "model override for selected harness(es)")
47+
models := fs.String("models", "", "per-harness model overrides (e.g., codex:o3,claude:sonnet)")
4648
automation := fs.String("automation", opts.AutomationMode, "automation mode (manual|plan|auto|yolo)")
4749
dryRun := fs.Bool("dry-run", false, "print review prompt without calling harnesses")
4850
verbose := fs.Bool("verbose", false, "print individual harness reviews")
@@ -90,6 +92,30 @@ func runReview(args []string, stdout io.Writer, stderr io.Writer) int {
9092
synth = reviewCfg.Harnesses[0]
9193
}
9294

95+
modelOverrides := make(map[string]string)
96+
for harnessName, modelName := range opts.AgentModels {
97+
if modelName == "" {
98+
continue
99+
}
100+
modelOverrides[harnessName] = modelName
101+
}
102+
if *models != "" {
103+
parsed, parseErr := parseHarnessModelOverrides(*models)
104+
if parseErr != nil {
105+
fmt.Fprintf(stderr, "dun review failed: invalid models: %v\n", parseErr)
106+
return dun.ExitUsageError
107+
}
108+
for harnessName, modelName := range parsed {
109+
modelOverrides[harnessName] = modelName
110+
}
111+
}
112+
harnessModel = strings.TrimSpace(*model)
113+
if len(modelOverrides) == 0 {
114+
harnessModelOverrides = nil
115+
} else {
116+
harnessModelOverrides = modelOverrides
117+
}
118+
93119
for _, doc := range docs {
94120
reviewPrompt := buildReviewPrompt(doc, principles, *principlesPath)
95121
if *dryRun {

docs/design/contracts/API-001-dun-cli.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ $ dun check --prompt
208208
**Options**:
209209
- `--config` : Path to config file (default `.dun/config.yaml` if present)
210210
- `--harness` : Agent harness (`codex`, `claude`, `gemini`, `opencode`, default from config)
211+
- `--model` : Model override for selected harness(es)
212+
- `--models` : Per-harness model overrides (e.g., `codex:o3,claude:sonnet`)
211213
- `--automation` : Automation mode (`manual`, `plan`, `auto`, `yolo`, default `auto`)
212214
- `--max-iterations` : Maximum iterations before stopping (default `100`)
213215
- `--dry-run` : Print prompt without calling harness

internal/dun/config.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ type Config struct {
1616
}
1717

1818
type AgentConfig struct {
19-
Cmd string `yaml:"cmd"`
20-
Harness string `yaml:"harness"`
21-
TimeoutMS int `yaml:"timeout_ms"`
22-
Mode string `yaml:"mode"`
23-
Automation string `yaml:"automation"`
19+
Cmd string `yaml:"cmd"`
20+
Harness string `yaml:"harness"`
21+
Model string `yaml:"model"`
22+
Models map[string]string `yaml:"models"`
23+
TimeoutMS int `yaml:"timeout_ms"`
24+
Mode string `yaml:"mode"`
25+
Automation string `yaml:"automation"`
2426
}
2527

2628
type GoConfig struct {
@@ -35,6 +37,8 @@ agent:
3537
automation: auto
3638
mode: auto
3739
timeout_ms: 300000
40+
model: ""
41+
models: {}
3842
go:
3943
coverage_threshold: 80
4044
`
@@ -54,6 +58,18 @@ func ApplyConfig(opts Options, cfg Config) Options {
5458
if cfg.Agent.Harness != "" {
5559
opts.AgentHarness = cfg.Agent.Harness
5660
}
61+
if cfg.Agent.Model != "" {
62+
opts.AgentModel = cfg.Agent.Model
63+
}
64+
if len(cfg.Agent.Models) > 0 {
65+
opts.AgentModels = make(map[string]string, len(cfg.Agent.Models))
66+
for harness, model := range cfg.Agent.Models {
67+
if model == "" {
68+
continue
69+
}
70+
opts.AgentModels[harness] = model
71+
}
72+
}
5773
if cfg.Agent.TimeoutMS > 0 {
5874
opts.AgentTimeout = time.Duration(cfg.Agent.TimeoutMS) * time.Millisecond
5975
}

internal/dun/config_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ func TestLoadConfigDefaultPath(t *testing.T) {
1313
if err := os.MkdirAll(filepath.Dir(cfgPath), 0755); err != nil {
1414
t.Fatalf("mkdir config dir: %v", err)
1515
}
16-
content := "agent:\n cmd: echo hi\n harness: codex\n timeout_ms: 120000\n mode: auto\n automation: plan\n" +
16+
content := "agent:\n cmd: echo hi\n harness: codex\n model: o3\n models:\n claude: sonnet\n timeout_ms: 120000\n mode: auto\n automation: plan\n" +
1717
"go:\n coverage_threshold: 95\n"
1818
if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil {
1919
t.Fatalf("write config: %v", err)
@@ -34,6 +34,12 @@ func TestLoadConfigDefaultPath(t *testing.T) {
3434
if opts.AgentHarness != "codex" {
3535
t.Fatalf("expected agent harness codex, got %q", opts.AgentHarness)
3636
}
37+
if opts.AgentModel != "o3" {
38+
t.Fatalf("expected agent model o3, got %q", opts.AgentModel)
39+
}
40+
if opts.AgentModels["claude"] != "sonnet" {
41+
t.Fatalf("expected agent model override for claude, got %q", opts.AgentModels["claude"])
42+
}
3743
if opts.AgentTimeout != 120*time.Second {
3844
t.Fatalf("expected timeout 120s, got %s", opts.AgentTimeout)
3945
}

internal/dun/harness.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ type HarnessConfig struct {
5858
// Command is the base command to execute (optional, uses default if empty)
5959
Command string
6060

61+
// Model selects the model for the harness (optional, uses harness default if empty)
62+
Model string
63+
6164
// WorkDir is the working directory for command execution
6265
WorkDir string
6366

@@ -184,6 +187,9 @@ func (h *ClaudeHarness) Execute(ctx context.Context, prompt string) (string, err
184187
"--input-format", "text",
185188
"--output-format", "text",
186189
}
190+
if h.config.Model != "" {
191+
args = append(args, "--model", h.config.Model)
192+
}
187193
switch h.config.AutomationMode {
188194
case AutomationPlan:
189195
args = append(args, "--permission-mode", "plan")
@@ -233,6 +239,9 @@ func (h *GeminiHarness) Execute(ctx context.Context, prompt string) (string, err
233239
"--prompt", "",
234240
"--output-format", "text",
235241
}
242+
if h.config.Model != "" {
243+
args = append(args, "--model", h.config.Model)
244+
}
236245
switch h.config.AutomationMode {
237246
case AutomationPlan:
238247
args = append(args, "--approval-mode", "plan")
@@ -280,7 +289,11 @@ func (h *CodexHarness) Name() string {
280289
// Uses exec --full-auto for autonomous execution.
281290
// Reference: ralph-orchestrator/crates/ralph-adapters/src/cli_backend.rs
282291
func (h *CodexHarness) Execute(ctx context.Context, prompt string) (string, error) {
283-
args := []string{"exec"}
292+
args := []string{}
293+
if h.config.Model != "" {
294+
args = append(args, "--model", h.config.Model)
295+
}
296+
args = append(args, "exec")
284297
switch h.config.AutomationMode {
285298
case AutomationPlan:
286299
args = append(args, "--sandbox", "read-only")
@@ -325,7 +338,11 @@ func (h *OpenCodeHarness) Name() string {
325338
// Execute runs the OpenCode CLI with the given prompt.
326339
// OpenCode expects the prompt as a positional message for `opencode run`.
327340
func (h *OpenCodeHarness) Execute(ctx context.Context, prompt string) (string, error) {
328-
args := []string{"run", prompt}
341+
args := []string{"run"}
342+
if h.config.Model != "" {
343+
args = append(args, "--model", h.config.Model)
344+
}
345+
args = append(args, prompt)
329346

330347
return h.runCommand(ctx, h.config.Command, prompt, args...)
331348
}
@@ -471,13 +488,14 @@ func formatEnv(env map[string]string) []string {
471488
var DefaultRegistry = NewHarnessRegistry()
472489

473490
// ExecuteHarness is a convenience function that executes a prompt using a harness from the default registry.
474-
func ExecuteHarness(ctx context.Context, harnessName, prompt string, automationMode AutomationMode, workDir string) (HarnessResult, error) {
491+
func ExecuteHarness(ctx context.Context, harnessName, prompt string, automationMode AutomationMode, workDir string, model string) (HarnessResult, error) {
475492
start := time.Now()
476493

477494
config := HarnessConfig{
478495
Name: harnessName,
479496
WorkDir: workDir,
480497
AutomationMode: automationMode,
498+
Model: model,
481499
}
482500

483501
harness, err := DefaultRegistry.Get(harnessName, config)
@@ -511,13 +529,14 @@ func ExecuteHarness(ctx context.Context, harnessName, prompt string, automationM
511529
}
512530

513531
// ExecuteHarnessWithOutput streams harness output while capturing the full response.
514-
func ExecuteHarnessWithOutput(ctx context.Context, harnessName, prompt string, automationMode AutomationMode, workDir string, stdoutWriter io.Writer, stderrWriter io.Writer) (HarnessResult, error) {
532+
func ExecuteHarnessWithOutput(ctx context.Context, harnessName, prompt string, automationMode AutomationMode, workDir string, model string, stdoutWriter io.Writer, stderrWriter io.Writer) (HarnessResult, error) {
515533
start := time.Now()
516534

517535
config := HarnessConfig{
518536
Name: harnessName,
519537
WorkDir: workDir,
520538
AutomationMode: automationMode,
539+
Model: model,
521540
StdoutWriter: stdoutWriter,
522541
StderrWriter: stderrWriter,
523542
}

0 commit comments

Comments
 (0)