Skip to content

Commit f8f6658

Browse files
wesmbeettlleclaude
authored
Add configurable model support for all agents (#126)
## Summary Adds `--model` flag support to all agents (codex, claude-code, gemini, copilot, opencode), allowing users to specify which model an agent should use. This lays the groundwork for the more comprehensive agent/model-per-reasoning-level configuration described in #125. ## Changes ### CLI - Add `--model` flag to `review`, `refine`, and `run` commands - Help text explains format varies by agent (e.g., opencode uses `provider/model`) ### Configuration - `default_model` in `~/.roborev/config.toml` - global default model - `model` in `.roborev.toml` - per-repo model override - Resolution priority: CLI flag > repo config > global config > agent default ### Agent Interface - All agents implement `WithModel(model string) Agent` - Model passed to underlying CLI where supported (`--model`, `-m`, etc.) ### Database & Sync - Added `model` column to Postgres schema (v1→v2 migration) - Model included in job sync between SQLite and Postgres - Model returned in job listing/detail API responses - COALESCE backfill ensures existing jobs get model populated on upsert ### Schema Management - Postgres schema now defined in `internal/storage/schemas/*.sql` files - Single source of truth, easy to diff between versions - v1→v2 migration tested with job data preservation ### Bug Fixes - Fixed `OpenCodeAgent.WithReasoning` to preserve Model field - Improved OpenCode tool-call filter documentation ## Relationship to #125 This PR provides the foundation for the model-per-reasoning-level feature proposed in #125: | This PR | Enables in #125 | |---------|-----------------| | `WithModel()` on all agents | Phase 2 model overrides | | `default_model` / `model` config | Fallback when no per-reasoning override | | `model` column in database | Already stores resolved model per job | | `ResolveModel()` function | Base for future `ResolveReviewModel(reasoning, ...)` | The database schema does not need further changes - the single `model` column stores whichever model was resolved at enqueue time, regardless of selection mechanism. ## Supersedes Supersedes #124 (OpenCode tool-call filtering) - that fix is included here along with improved documentation. ## Test Plan - [x] Unit tests for `ResolveModel()` config resolution - [x] Unit tests for `WithModel()` persistence across all agents - [x] Unit tests for model flag generation in `buildArgs()` - [x] Integration tests for Postgres v1→v2 migration - [x] Integration tests for COALESCE model backfill (Postgres & SQLite) - [x] All existing tests pass --------- Co-authored-by: Cesar Delgado <beettlle@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a12693f commit f8f6658

37 files changed

+1432
-285
lines changed

cmd/roborev/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,7 @@ func reviewCmd() *cobra.Command {
566566
repoPath string
567567
sha string
568568
agent string
569+
model string
569570
reasoning string
570571
quiet bool
571572
dirty bool
@@ -748,6 +749,7 @@ Examples:
748749
"repo_path": root,
749750
"git_ref": gitRef,
750751
"agent": agent,
752+
"model": model,
751753
"reasoning": reasoning,
752754
"diff_content": diffContent,
753755
})
@@ -808,6 +810,7 @@ Examples:
808810
cmd.Flags().StringVar(&repoPath, "repo", "", "path to git repository (default: current directory)")
809811
cmd.Flags().StringVar(&sha, "sha", "HEAD", "commit SHA to review (used when no positional args)")
810812
cmd.Flags().StringVar(&agent, "agent", "", "agent to use (codex, claude-code, gemini, copilot, opencode)")
813+
cmd.Flags().StringVar(&model, "model", "", "model for agent (format varies: opencode uses provider/model, others use model name)")
811814
cmd.Flags().StringVar(&reasoning, "reasoning", "", "reasoning level: thorough (default), standard, or fast")
812815
cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "suppress output (for use in hooks)")
813816
cmd.Flags().BoolVar(&dirty, "dirty", false, "review uncommitted changes instead of a commit")

cmd/roborev/main_test.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ func (f failingAgent) Review(ctx context.Context, repoPath, commitSHA, prompt st
190190

191191
func (f failingAgent) WithReasoning(level agent.ReasoningLevel) agent.Agent { return f }
192192
func (f failingAgent) WithAgentic(agentic bool) agent.Agent { return f }
193+
func (f failingAgent) WithModel(model string) agent.Agent { return f }
193194

194195
func TestEnqueueReviewRefine(t *testing.T) {
195196
t.Run("returns job ID on success", func(t *testing.T) {
@@ -367,7 +368,7 @@ func TestRunRefineSurfacesResponseErrors(t *testing.T) {
367368
}
368369
defer os.Chdir(origDir)
369370

370-
if err := runRefine("test", "", 1, true, false, false, ""); err == nil {
371+
if err := runRefine("test", "", "", 1, true, false, false, ""); err == nil {
371372
t.Fatal("expected error, got nil")
372373
}
373374
}
@@ -397,7 +398,7 @@ func TestRunRefineQuietNonTTYTimerOutput(t *testing.T) {
397398
defer func() { isTerminal = origIsTerminal }()
398399

399400
output := captureStdout(t, func() {
400-
if err := runRefine("test", "", 1, true, false, false, ""); err == nil {
401+
if err := runRefine("test", "", "", 1, true, false, false, ""); err == nil {
401402
t.Fatal("expected error, got nil")
402403
}
403404
})
@@ -438,7 +439,7 @@ func TestRunRefineStopsLiveTimerOnAgentError(t *testing.T) {
438439
defer agent.Register(agent.NewTestAgent())
439440

440441
output := captureStdout(t, func() {
441-
if err := runRefine("test", "", 1, true, false, false, ""); err == nil {
442+
if err := runRefine("test", "", "", 1, true, false, false, ""); err == nil {
442443
t.Fatal("expected error, got nil")
443444
}
444445
})
@@ -484,7 +485,7 @@ func TestRunRefineAgentErrorRetriesWithoutApplyingChanges(t *testing.T) {
484485

485486
output := captureStdout(t, func() {
486487
// With 2 iterations and a failing agent, should exhaust iterations
487-
err := runRefine("test", "", 2, true, false, false, "")
488+
err := runRefine("test", "", "", 2, true, false, false, "")
488489
if err == nil {
489490
t.Fatal("expected error after exhausting iterations, got nil")
490491
}
@@ -1004,6 +1005,10 @@ func (a *changingAgent) WithAgentic(agentic bool) agent.Agent {
10041005
return a
10051006
}
10061007

1008+
func (a *changingAgent) WithModel(model string) agent.Agent {
1009+
return a
1010+
}
1011+
10071012
func TestRefineLoopStaysOnFailedFixChain(t *testing.T) {
10081013
setupFastPolling(t)
10091014
repoDir, _ := setupRefineRepo(t)
@@ -1192,7 +1197,7 @@ func TestRefineLoopStaysOnFailedFixChain(t *testing.T) {
11921197
agent.Register(changer)
11931198
defer agent.Register(agent.NewTestAgent())
11941199

1195-
if err := runRefine("test", "", 2, true, false, false, ""); err == nil {
1200+
if err := runRefine("test", "", "", 2, true, false, false, ""); err == nil {
11961201
t.Fatal("expected error from reaching max iterations")
11971202
}
11981203

@@ -1392,7 +1397,7 @@ func TestRefinePendingJobWaitDoesNotConsumeIteration(t *testing.T) {
13921397
// an iteration, this would fail with "max iterations reached". Since the
13931398
// pending job transitions to Done with a passing review (and no failed
13941399
// reviews exist), refine should succeed.
1395-
err = runRefine("test", "", 1, true, false, false, "")
1400+
err = runRefine("test", "", "", 1, true, false, false, "")
13961401

13971402
// Should succeed - all reviews pass after waiting for the pending one
13981403
if err != nil {

cmd/roborev/refine.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ var postCommitWaitDelay = 1 * time.Second
3131
func refineCmd() *cobra.Command {
3232
var (
3333
agentName string
34+
model string
3435
reasoning string
3536
maxIterations int
3637
quiet bool
@@ -61,11 +62,12 @@ Use --since to specify a starting commit when on the main branch or to
6162
limit how far back to look for reviews to address.`,
6263
RunE: func(cmd *cobra.Command, args []string) error {
6364
unsafeFlagChanged := cmd.Flags().Changed("allow-unsafe-agents")
64-
return runRefine(agentName, reasoning, maxIterations, quiet, allowUnsafeAgents, unsafeFlagChanged, since)
65+
return runRefine(agentName, model, reasoning, maxIterations, quiet, allowUnsafeAgents, unsafeFlagChanged, since)
6566
},
6667
}
6768

6869
cmd.Flags().StringVar(&agentName, "agent", "", "agent to use for addressing findings (default: from config)")
70+
cmd.Flags().StringVar(&model, "model", "", "model for agent (format varies: opencode uses provider/model, others use model name)")
6971
cmd.Flags().StringVar(&reasoning, "reasoning", "", "reasoning level: fast, standard (default), or thorough")
7072
cmd.Flags().IntVar(&maxIterations, "max-iterations", 10, "maximum refinement iterations")
7173
cmd.Flags().BoolVar(&quiet, "quiet", false, "suppress agent output, show elapsed time instead")
@@ -185,7 +187,7 @@ func validateRefineContext(since string) (repoPath, currentBranch, defaultBranch
185187
return repoPath, currentBranch, defaultBranch, mergeBase, nil
186188
}
187189

188-
func runRefine(agentName, reasoningStr string, maxIterations int, quiet bool, allowUnsafeAgents bool, unsafeFlagChanged bool, since string) error {
190+
func runRefine(agentName, modelStr, reasoningStr string, maxIterations int, quiet bool, allowUnsafeAgents bool, unsafeFlagChanged bool, since string) error {
189191
// 1. Validate git and branch context (before touching daemon)
190192
repoPath, currentBranch, defaultBranch, mergeBase, err := validateRefineContext(since)
191193
if err != nil {
@@ -225,8 +227,11 @@ func runRefine(agentName, reasoningStr string, maxIterations int, quiet bool, al
225227
}
226228
reasoningLevel := agent.ParseReasoningLevel(resolvedReasoning)
227229

228-
// Get the agent with configured reasoning level
229-
addressAgent, err := selectRefineAgent(resolvedAgent, reasoningLevel)
230+
// Resolve model from CLI or config
231+
resolvedModel := config.ResolveModel(modelStr, repoPath, cfg)
232+
233+
// Get the agent with configured reasoning level and model
234+
addressAgent, err := selectRefineAgent(resolvedAgent, reasoningLevel, resolvedModel)
230235
if err != nil {
231236
return fmt.Errorf("no agent available: %w", err)
232237
}
@@ -777,18 +782,18 @@ func applyWorktreeChanges(repoPath, worktreePath string) error {
777782
return nil
778783
}
779784

780-
func selectRefineAgent(resolvedAgent string, reasoningLevel agent.ReasoningLevel) (agent.Agent, error) {
785+
func selectRefineAgent(resolvedAgent string, reasoningLevel agent.ReasoningLevel, model string) (agent.Agent, error) {
781786
if resolvedAgent == "codex" && agent.IsAvailable("codex") {
782787
baseAgent, err := agent.Get("codex")
783788
if err != nil {
784789
return nil, err
785790
}
786-
return baseAgent.WithReasoning(reasoningLevel), nil
791+
return baseAgent.WithReasoning(reasoningLevel).WithModel(model), nil
787792
}
788793

789794
baseAgent, err := agent.GetAvailable(resolvedAgent)
790795
if err != nil {
791796
return nil, err
792797
}
793-
return baseAgent.WithReasoning(reasoningLevel), nil
798+
return baseAgent.WithReasoning(reasoningLevel).WithModel(model), nil
794799
}

cmd/roborev/refine_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ var _ daemon.Client = (*mockDaemonClient)(nil)
126126
func TestSelectRefineAgentCodexFallback(t *testing.T) {
127127
t.Setenv("PATH", "")
128128

129-
selected, err := selectRefineAgent("codex", agent.ReasoningFast)
129+
selected, err := selectRefineAgent("codex", agent.ReasoningFast, "")
130130
if err != nil {
131131
t.Fatalf("selectRefineAgent failed: %v", err)
132132
}
@@ -234,7 +234,7 @@ func TestSelectRefineAgentCodexUsesRequestedReasoning(t *testing.T) {
234234

235235
t.Setenv("PATH", tmpDir)
236236

237-
selected, err := selectRefineAgent("codex", agent.ReasoningFast)
237+
selected, err := selectRefineAgent("codex", agent.ReasoningFast, "")
238238
if err != nil {
239239
t.Fatalf("selectRefineAgent failed: %v", err)
240240
}
@@ -267,7 +267,7 @@ func TestSelectRefineAgentCodexFallbackUsesRequestedReasoning(t *testing.T) {
267267
t.Setenv("PATH", tmpDir)
268268

269269
// Request an unavailable agent (claude), codex should be used as fallback
270-
selected, err := selectRefineAgent("claude", agent.ReasoningThorough)
270+
selected, err := selectRefineAgent("claude", agent.ReasoningThorough, "")
271271
if err != nil {
272272
t.Fatalf("selectRefineAgent failed: %v", err)
273273
}

cmd/roborev/run.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
func runCmd() *cobra.Command {
2121
var (
2222
agentName string
23+
model string
2324
reasoning string
2425
wait bool
2526
quiet bool
@@ -58,11 +59,12 @@ Examples:
5859
cat instructions.txt | roborev run --wait
5960
`,
6061
RunE: func(cmd *cobra.Command, args []string) error {
61-
return runPrompt(cmd, args, agentName, reasoning, wait, quiet, !noContext, agentic)
62+
return runPrompt(cmd, args, agentName, model, reasoning, wait, quiet, !noContext, agentic)
6263
},
6364
}
6465

6566
cmd.Flags().StringVar(&agentName, "agent", "", "agent to use (default: from config)")
67+
cmd.Flags().StringVar(&model, "model", "", "model for agent (format varies: opencode uses provider/model, others use model name)")
6668
cmd.Flags().StringVar(&reasoning, "reasoning", "", "reasoning level: fast, standard, or thorough (default)")
6769
cmd.Flags().BoolVar(&wait, "wait", false, "wait for job to complete and show result")
6870
cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "suppress output (just enqueue)")
@@ -81,7 +83,7 @@ func promptCmd() *cobra.Command {
8183
return cmd
8284
}
8385

84-
func runPrompt(cmd *cobra.Command, args []string, agentName, reasoningStr string, wait, quiet, includeContext, agentic bool) error {
86+
func runPrompt(cmd *cobra.Command, args []string, agentName, modelStr, reasoningStr string, wait, quiet, includeContext, agentic bool) error {
8587
// Get prompt from args or stdin
8688
var promptText string
8789
if len(args) > 0 {
@@ -136,6 +138,7 @@ func runPrompt(cmd *cobra.Command, args []string, agentName, reasoningStr string
136138
"repo_path": repoRoot,
137139
"git_ref": "run",
138140
"agent": agentName,
141+
"model": modelStr,
139142
"reasoning": reasoningStr,
140143
"custom_prompt": fullPrompt,
141144
"agentic": agentic,

e2e_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func TestDatabaseIntegration(t *testing.T) {
103103
t.Fatalf("GetOrCreateCommit failed: %v", err)
104104
}
105105

106-
job, err := db.EnqueueJob(repo.ID, commit.ID, "abc123", "codex", "")
106+
job, err := db.EnqueueJob(repo.ID, commit.ID, "abc123", "codex", "", "")
107107
if err != nil {
108108
t.Fatalf("EnqueueJob failed: %v", err)
109109
}

internal/agent/agent.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ type Agent interface {
5252
// In agentic mode, agents can edit files and run commands.
5353
// If false, agents operate in read-only review mode.
5454
WithAgentic(agentic bool) Agent
55+
56+
// WithModel returns a copy of the agent configured to use the specified model.
57+
// Agents that don't support model selection may return themselves unchanged.
58+
// For opencode, the model format is "provider/model" (e.g., "anthropic/claude-sonnet-4-20250514").
59+
WithModel(model string) Agent
5560
}
5661

5762
// CommandAgent is an agent that uses an external command

0 commit comments

Comments
 (0)