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
22 changes: 8 additions & 14 deletions cmd/roborev/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -803,25 +803,19 @@ func runFixAgent(cmd *cobra.Command, repoPath, agentName, model, reasoning, prom
}

// Resolve agent and model via fix workflow config.
agentName = config.ResolveAgentForWorkflow(agentName, repoPath, cfg, "fix", reasoning)
backupAgent := config.ResolveBackupAgentForWorkflow(repoPath, cfg, "fix")
resolution := agent.ResolveWorkflowConfig(
agentName, repoPath, cfg, "fix", reasoning,
)
agentName = resolution.PreferredAgent

a, err := agent.GetAvailableWithConfig(agentName, cfg, backupAgent)
a, err := agent.GetAvailableWithConfig(
agentName, cfg, resolution.BackupAgent,
)
if err != nil {
return fmt.Errorf("get agent: %w", err)
}

// Use backup model when the backup agent was selected and no
// explicit model was passed via CLI.
preferredForAnalyze := config.ResolveAgentForWorkflow(agentName, repoPath, cfg, "fix", reasoning)
usingBackup := backupAgent != "" &&
agent.CanonicalName(a.Name()) == agent.CanonicalName(backupAgent) &&
agent.CanonicalName(a.Name()) != agent.CanonicalName(preferredForAnalyze)
if usingBackup && model == "" {
model = config.ResolveBackupModelForWorkflow(repoPath, cfg, "fix")
} else {
model = resolveFixModel(a.Name(), model, repoPath, cfg, reasoning)
}
model = resolution.ModelForSelectedAgent(a.Name(), model)

// Configure agent: agentic mode, with model and reasoning
reasoningLevel := agent.ParseReasoningLevel(reasoning)
Expand Down
31 changes: 14 additions & 17 deletions cmd/roborev/fix.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,11 @@ func resolveFixModel(
selectedAgent, cliModel, repoPath string,
cfg *config.Config, reasoning string,
) string {
return agent.ResolveWorkflowModelForAgent(
selectedAgent, cliModel, repoPath, cfg, "fix", reasoning,
resolution := agent.ResolveWorkflowConfig(
"", repoPath, cfg, "fix", reasoning,
)
return resolution.ModelForSelectedAgent(
selectedAgent, cliModel,
)
}

Expand All @@ -339,26 +342,20 @@ func resolveFixAgent(repoPath string, opts fixOptions) (agent.Agent, error) {
return nil, fmt.Errorf("resolve fix reasoning: %w", err)
}

agentName := config.ResolveAgentForWorkflow(opts.agentName, repoPath, cfg, "fix", reasoning)
backupAgent := config.ResolveBackupAgentForWorkflow(repoPath, cfg, "fix")
resolution := agent.ResolveWorkflowConfig(
opts.agentName, repoPath, cfg, "fix", reasoning,
)

a, err := agent.GetAvailableWithConfig(agentName, cfg, backupAgent)
a, err := agent.GetAvailableWithConfig(
resolution.PreferredAgent, cfg, resolution.BackupAgent,
)
if err != nil {
return nil, fmt.Errorf("get agent: %w", err)
}

// Use backup model when the backup agent was selected and no
// explicit model was passed via CLI.
preferredAgent := config.ResolveAgentForWorkflow(opts.agentName, repoPath, cfg, "fix", reasoning)
usingBackup := backupAgent != "" &&
agent.CanonicalName(a.Name()) == agent.CanonicalName(backupAgent) &&
agent.CanonicalName(a.Name()) != agent.CanonicalName(preferredAgent)
var modelStr string
if usingBackup && opts.model == "" {
modelStr = config.ResolveBackupModelForWorkflow(repoPath, cfg, "fix")
} else {
modelStr = resolveFixModel(a.Name(), opts.model, repoPath, cfg, reasoning)
}
modelStr := resolution.ModelForSelectedAgent(
a.Name(), opts.model,
)

reasoningLevel := agent.ParseReasoningLevel(reasoning)
a = a.WithAgentic(true).WithReasoning(reasoningLevel)
Expand Down
34 changes: 16 additions & 18 deletions cmd/roborev/refine.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,10 @@ func runRefine(ctx RunContext, opts refineOptions) error {
}
reasoningLevel := agent.ParseReasoningLevel(resolvedReasoning)

// Resolve agent for refine workflow at this reasoning level
resolvedAgent := config.ResolveAgentForWorkflow(opts.agentName, repoPath, cfg, "refine", resolvedReasoning)
backupAgent := config.ResolveBackupAgentForWorkflow(repoPath, cfg, "refine")
// Resolve agent/model preferences for refine at this reasoning level.
resolution := agent.ResolveWorkflowConfig(
opts.agentName, repoPath, cfg, "refine", resolvedReasoning,
)
allowUnsafe := resolveAllowUnsafeAgents(opts.allowUnsafeAgents, opts.unsafeFlagChanged, cfg)
agent.SetAllowUnsafeAgents(allowUnsafe)
if cfg != nil {
Expand All @@ -367,12 +368,14 @@ func runRefine(ctx RunContext, opts refineOptions) error {
// Get the agent with configured reasoning level (model applied after
// backup determination to avoid baking the primary model into a
// backup agent).
addressAgent, err := selectRefineAgent(cfg, resolvedAgent, reasoningLevel, backupAgent)
addressAgent, err := selectRefineAgent(
cfg, resolution.PreferredAgent, reasoningLevel, resolution.BackupAgent,
)
if err != nil {
return fmt.Errorf("no agent available: %w", err)
}
addressAgent, _ = applyModelForAgent(
addressAgent, resolvedAgent, backupAgent,
addressAgent, resolution.PreferredAgent, resolution.BackupAgent,
opts.model, repoPath, cfg, "refine", resolvedReasoning,
)
fmt.Printf("Using agent: %s\n", addressAgent.Name())
Expand Down Expand Up @@ -1214,20 +1217,15 @@ func applyModelForAgent(
workflow string,
reasoning string,
) (agent.Agent, string) {
usingBackup := backupAgentName != "" &&
agent.CanonicalName(a.Name()) == agent.CanonicalName(backupAgentName) &&
agent.CanonicalName(a.Name()) != agent.CanonicalName(preferredAgent)

var model string
if usingBackup && cliModel == "" {
model = config.ResolveBackupModelForWorkflow(
repoPath, cfg, workflow,
)
} else {
model = agent.ResolveWorkflowModelForAgent(
a.Name(), cliModel, repoPath, cfg, workflow, reasoning,
)
resolution := agent.WorkflowConfig{
RepoPath: repoPath,
GlobalConfig: cfg,
Workflow: workflow,
Reasoning: reasoning,
PreferredAgent: preferredAgent,
BackupAgent: backupAgentName,
}
model := resolution.ModelForSelectedAgent(a.Name(), cliModel)

if model != "" {
a = a.WithModel(model)
Expand Down
13 changes: 8 additions & 5 deletions cmd/roborev/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,12 +384,15 @@ func runLocalReview(cmd *cobra.Command, repoPath, gitRef, diffContent, agentName
workflow = reviewType
}

// Resolve agent using workflow-specific resolution (matches daemon behavior)
preferredAgent := config.ResolveAgentForWorkflow(agentName, repoPath, cfg, workflow, reasoning)
backupAgent := config.ResolveBackupAgentForWorkflow(repoPath, cfg, workflow)
// Resolve agent/model preferences (matches daemon behavior).
resolution := agent.ResolveWorkflowConfig(
agentName, repoPath, cfg, workflow, reasoning,
)

// Get the agent (try backup before hardcoded chain)
a, err := agent.GetAvailableWithConfig(preferredAgent, cfg, backupAgent)
a, err := agent.GetAvailableWithConfig(
resolution.PreferredAgent, cfg, resolution.BackupAgent,
)
if err != nil {
return fmt.Errorf("get agent: %w", err)
}
Expand All @@ -399,7 +402,7 @@ func runLocalReview(cmd *cobra.Command, repoPath, gitRef, diffContent, agentName
reasoningLevel := agent.ParseReasoningLevel(reasoning)
a = a.WithReasoning(reasoningLevel)
a, model = applyModelForAgent(
a, preferredAgent, backupAgent,
a, resolution.PreferredAgent, resolution.BackupAgent,
model, repoPath, cfg, workflow, reasoning,
)

Expand Down
68 changes: 68 additions & 0 deletions internal/agent/model_resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,74 @@ import (
"github.com/roborev-dev/roborev/internal/config"
)

// WorkflowConfig captures the workflow-specific agent resolution context
// shared by CLI, daemon, and batch review callers.
type WorkflowConfig struct {
RepoPath string
GlobalConfig *config.Config
Workflow string
Reasoning string
PreferredAgent string
BackupAgent string
}

// ResolveWorkflowConfig resolves the preferred and backup agents for a
// workflow while retaining the workflow and reasoning context needed to
// resolve the final model after an agent has been selected.
func ResolveWorkflowConfig(
cliAgent, repoPath string,
globalCfg *config.Config,
workflow, reasoning string,
) WorkflowConfig {
return WorkflowConfig{
RepoPath: repoPath,
GlobalConfig: globalCfg,
Workflow: workflow,
Reasoning: reasoning,
PreferredAgent: config.ResolveAgentForWorkflow(cliAgent, repoPath, globalCfg, workflow, reasoning),
BackupAgent: config.ResolveBackupAgentForWorkflow(repoPath, globalCfg, workflow),
}
}

// AgentMatches reports whether two agent names refer to the same logical
// agent after alias and ACP-name normalization.
func (w WorkflowConfig) AgentMatches(left, right string) bool {
return workflowModelComparableAgentName(left, w.GlobalConfig) ==
workflowModelComparableAgentName(right, w.GlobalConfig)
}

// UsesBackupAgent reports whether the selected agent is the configured
// backup rather than the preferred primary for this workflow.
func (w WorkflowConfig) UsesBackupAgent(selectedAgent string) bool {
return w.BackupAgent != "" &&
w.AgentMatches(selectedAgent, w.BackupAgent) &&
!w.AgentMatches(selectedAgent, w.PreferredAgent)
}

// BackupModel returns the workflow backup model override, if any.
func (w WorkflowConfig) BackupModel() string {
return config.ResolveBackupModelForWorkflow(
w.RepoPath, w.GlobalConfig, w.Workflow,
)
}

// ModelForSelectedAgent resolves the model for the actual selected
// agent. Backup agents use the workflow backup model when no explicit
// CLI model was provided; otherwise the workflow/default precedence used
// by ResolveWorkflowModelForAgent is preserved.
func (w WorkflowConfig) ModelForSelectedAgent(
selectedAgent, cliModel string,
) string {
if w.UsesBackupAgent(selectedAgent) &&
strings.TrimSpace(cliModel) == "" {
return w.BackupModel()
}
return ResolveWorkflowModelForAgent(
selectedAgent, cliModel, w.RepoPath,
w.GlobalConfig, w.Workflow, w.Reasoning,
)
}

// ResolveWorkflowModelForAgent resolves a workflow model for the actual
// agent that will run. If that agent differs from the generic default
// agent and no explicit model was provided, generic default_model is
Expand Down
31 changes: 31 additions & 0 deletions internal/agent/model_resolution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,34 @@ agent = "custom-acp"
)
require.Equal(t, "gpt-5.4", got, "ResolveWorkflowModelForAgent() = %q, want %q", got, "gpt-5.4")
}

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

cfg := &config.Config{
ReviewAgent: "gemini",
ReviewBackupAgent: "claude",
ReviewBackupModel: "claude-sonnet",
}

resolution := ResolveWorkflowConfig("", t.TempDir(), cfg, "review", "standard")

require.Equal(t, "gemini", resolution.PreferredAgent)
require.Equal(t, "claude", resolution.BackupAgent)
require.Equal(t, "claude-sonnet", resolution.ModelForSelectedAgent("claude-code", ""))
}

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

cfg := &config.Config{
DefaultAgent: "codex",
DefaultModel: "gpt-5.4",
ReviewAgent: "gemini",
ReviewBackupAgent: "claude",
}

resolution := ResolveWorkflowConfig("", t.TempDir(), cfg, "review", "standard")

require.Empty(t, resolution.ModelForSelectedAgent("claude-code", ""))
}
28 changes: 11 additions & 17 deletions internal/daemon/ci_poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -562,36 +562,30 @@ func (p *CIPoller) processPR(ctx context.Context, ghRepo string, pr ghPR, cfg *c
workflow = rt
}

// Resolve agent through workflow config when not explicitly set
resolvedAgent := config.ResolveAgentForWorkflow(ag, repo.RootPath, cfg, workflow, reasoning)
backupAgent := config.ResolveBackupAgentForWorkflow(repo.RootPath, cfg, workflow)
// Resolve agent through workflow config when not explicitly set.
resolution := agent.ResolveWorkflowConfig(
ag, repo.RootPath, cfg, workflow, reasoning,
)
resolvedAgent := resolution.PreferredAgent
if p.agentResolverFn != nil {
name, err := p.agentResolverFn(resolvedAgent)
if err != nil {
rollback("No agent available — check agent config or quota")
return fmt.Errorf("no review agent available for type=%s: %w", rt, err)
}
resolvedAgent = name
} else if resolved, err := agent.GetAvailableWithConfig(resolvedAgent, cfg, backupAgent); err != nil {
} else if resolved, err := agent.GetAvailableWithConfig(
resolvedAgent, cfg, resolution.BackupAgent,
); err != nil {
rollback("No agent available — check agent config or quota")
return fmt.Errorf("no review agent available for type=%s: %w", rt, err)
} else {
resolvedAgent = resolved.Name()
}

// Use backup model when the backup agent was selected
preferredForWorkflow := config.ResolveAgentForWorkflow(ag, repo.RootPath, cfg, workflow, reasoning)
usingBackup := backupAgent != "" &&
agent.CanonicalName(resolvedAgent) == agent.CanonicalName(backupAgent) &&
agent.CanonicalName(resolvedAgent) != agent.CanonicalName(preferredForWorkflow)
var resolvedModel string
if usingBackup && cfg.CI.Model == "" {
resolvedModel = config.ResolveBackupModelForWorkflow(repo.RootPath, cfg, workflow)
} else {
resolvedModel = agent.ResolveWorkflowModelForAgent(
resolvedAgent, cfg.CI.Model, repo.RootPath, cfg, workflow, reasoning,
)
}
resolvedModel := resolution.ModelForSelectedAgent(
resolvedAgent, cfg.CI.Model,
)

job, err := p.db.EnqueueJob(storage.EnqueueOpts{
RepoID: repo.ID,
Expand Down
Loading
Loading