Skip to content

Commit 2ea9048

Browse files
wesmclaude
andauthored
Fix agent resolution and improve skill definitions (#449)
## Summary - Add variadic `backups ...string` parameter to `GetAvailable` and `GetAvailableWithConfig` so configured backup agents are tried before the hardcoded codex→claude-code→gemini→... fallback chain - Update all call sites (handleEnqueue, handleFixJob, CI poller, review, fix, analyze, refine, batch) to resolve the backup agent from config via `ResolveBackupAgentForWorkflow` and pass it through - When the backup agent is selected and no explicit model was requested, use `ResolveBackupModelForWorkflow` for the model instead of the default model - Fix backup agent in refine and review workflows inheriting the primary agent's model instead of resolving its own backup model - Extract shared `applyModelForAgent()` helper used by both refine and review for backup-vs-primary model resolution, replacing inline duplicated logic - Add regression tests that call `applyModelForAgent` directly to verify backup agent model inheritance and empty-model guard - Add invocation guards to review skills preventing false triggers when users paste existing review results - Make fix skill check conversation context before running discovery commands, skipping fetch when findings are already present; require matching pasted findings to jobs by commit SHA before closing - Replace "ask to commit" in fix skill with deference to project CLAUDE.md conventions - Add general principle to all skills: treat instructions as guidelines, use conversation context, defer to project-level config on conflicts 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 228b03c commit 2ea9048

25 files changed

Lines changed: 637 additions & 58 deletions

File tree

cmd/roborev/analyze.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -806,17 +806,27 @@ func runFixAgent(cmd *cobra.Command, repoPath, agentName, model, reasoning, prom
806806
cliAgentChanged := agentName != "" &&
807807
agent.CanonicalName(agentName) != agent.CanonicalName(configAgent)
808808
agentName = config.ResolveAgentForWorkflow(agentName, repoPath, cfg, "fix", reasoning)
809-
if cliAgentChanged && model == "" {
810-
model = config.ResolveWorkflowModel(repoPath, cfg, "fix", reasoning)
811-
} else {
812-
model = config.ResolveModelForWorkflow(model, repoPath, cfg, "fix", reasoning)
813-
}
809+
backupAgent := config.ResolveBackupAgentForWorkflow(repoPath, cfg, "fix")
814810

815-
a, err := agent.GetAvailableWithConfig(agentName, cfg)
811+
a, err := agent.GetAvailableWithConfig(agentName, cfg, backupAgent)
816812
if err != nil {
817813
return fmt.Errorf("get agent: %w", err)
818814
}
819815

816+
// Use backup model when the backup agent was selected and no
817+
// explicit model was passed via CLI.
818+
preferredForAnalyze := config.ResolveAgentForWorkflow(agentName, repoPath, cfg, "fix", reasoning)
819+
usingBackup := backupAgent != "" &&
820+
agent.CanonicalName(a.Name()) == agent.CanonicalName(backupAgent) &&
821+
agent.CanonicalName(a.Name()) != agent.CanonicalName(preferredForAnalyze)
822+
if usingBackup && model == "" {
823+
model = config.ResolveBackupModelForWorkflow(repoPath, cfg, "fix")
824+
} else if cliAgentChanged && model == "" {
825+
model = config.ResolveWorkflowModel(repoPath, cfg, "fix", reasoning)
826+
} else {
827+
model = config.ResolveModelForWorkflow(model, repoPath, cfg, "fix", reasoning)
828+
}
829+
820830
// Configure agent: agentic mode, with model and reasoning
821831
reasoningLevel := agent.ParseReasoningLevel(reasoning)
822832
a = a.WithAgentic(true).WithReasoning(reasoningLevel)

cmd/roborev/fix.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,13 +326,26 @@ func resolveFixAgent(repoPath string, opts fixOptions) (agent.Agent, error) {
326326
}
327327

328328
agentName := config.ResolveAgentForWorkflow(opts.agentName, repoPath, cfg, "fix", reasoning)
329-
modelStr := resolveFixModel(opts.agentName, opts.model, repoPath, cfg, reasoning)
329+
backupAgent := config.ResolveBackupAgentForWorkflow(repoPath, cfg, "fix")
330330

331-
a, err := agent.GetAvailableWithConfig(agentName, cfg)
331+
a, err := agent.GetAvailableWithConfig(agentName, cfg, backupAgent)
332332
if err != nil {
333333
return nil, fmt.Errorf("get agent: %w", err)
334334
}
335335

336+
// Use backup model when the backup agent was selected and no
337+
// explicit model was passed via CLI.
338+
preferredAgent := config.ResolveAgentForWorkflow(opts.agentName, repoPath, cfg, "fix", reasoning)
339+
usingBackup := backupAgent != "" &&
340+
agent.CanonicalName(a.Name()) == agent.CanonicalName(backupAgent) &&
341+
agent.CanonicalName(a.Name()) != agent.CanonicalName(preferredAgent)
342+
var modelStr string
343+
if usingBackup && opts.model == "" {
344+
modelStr = config.ResolveBackupModelForWorkflow(repoPath, cfg, "fix")
345+
} else {
346+
modelStr = resolveFixModel(opts.agentName, opts.model, repoPath, cfg, reasoning)
347+
}
348+
336349
reasoningLevel := agent.ParseReasoningLevel(reasoning)
337350
a = a.WithAgentic(true).WithReasoning(reasoningLevel)
338351
if modelStr != "" {

cmd/roborev/refine.go

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -355,20 +355,24 @@ func runRefine(ctx RunContext, opts refineOptions) error {
355355

356356
// Resolve agent for refine workflow at this reasoning level
357357
resolvedAgent := config.ResolveAgentForWorkflow(opts.agentName, repoPath, cfg, "refine", resolvedReasoning)
358+
backupAgent := config.ResolveBackupAgentForWorkflow(repoPath, cfg, "refine")
358359
allowUnsafe := resolveAllowUnsafeAgents(opts.allowUnsafeAgents, opts.unsafeFlagChanged, cfg)
359360
agent.SetAllowUnsafeAgents(allowUnsafe)
360361
if cfg != nil {
361362
agent.SetAnthropicAPIKey(cfg.AnthropicAPIKey)
362363
}
363364

364-
// Resolve model for refine workflow at this reasoning level
365-
resolvedModel := config.ResolveModelForWorkflow(opts.model, repoPath, cfg, "refine", resolvedReasoning)
366-
367-
// Get the agent with configured reasoning level and model
368-
addressAgent, err := selectRefineAgent(cfg, resolvedAgent, reasoningLevel, resolvedModel)
365+
// Get the agent with configured reasoning level (model applied after
366+
// backup determination to avoid baking the primary model into a
367+
// backup agent).
368+
addressAgent, err := selectRefineAgent(cfg, resolvedAgent, reasoningLevel, backupAgent)
369369
if err != nil {
370370
return fmt.Errorf("no agent available: %w", err)
371371
}
372+
addressAgent, _ = applyModelForAgent(
373+
addressAgent, resolvedAgent, backupAgent,
374+
opts.model, repoPath, cfg, "refine", resolvedReasoning,
375+
)
372376
fmt.Printf("Using agent: %s\n", addressAgent.Name())
373377

374378
// 3. Refinement loop
@@ -1149,10 +1153,46 @@ func verifyRepoState(
11491153
return nil
11501154
}
11511155

1152-
func selectRefineAgent(cfg *config.Config, resolvedAgent string, reasoningLevel agent.ReasoningLevel, model string) (agent.Agent, error) {
1153-
baseAgent, err := agent.GetAvailableWithConfig(resolvedAgent, cfg)
1156+
func selectRefineAgent(cfg *config.Config, resolvedAgent string, reasoningLevel agent.ReasoningLevel, backups ...string) (agent.Agent, error) {
1157+
baseAgent, err := agent.GetAvailableWithConfig(resolvedAgent, cfg, backups...)
11541158
if err != nil {
11551159
return nil, err
11561160
}
1157-
return baseAgent.WithReasoning(reasoningLevel).WithModel(model), nil
1161+
return baseAgent.WithReasoning(reasoningLevel), nil
1162+
}
1163+
1164+
// applyModelForAgent resolves the correct model for the selected agent
1165+
// and applies it. When the selected agent is the configured backup (not
1166+
// the preferred primary), the backup model is used instead of the
1167+
// primary model. Returns the agent with the model applied (if any) and
1168+
// the resolved model string.
1169+
func applyModelForAgent(
1170+
a agent.Agent,
1171+
preferredAgent string,
1172+
backupAgentName string,
1173+
cliModel string,
1174+
repoPath string,
1175+
cfg *config.Config,
1176+
workflow string,
1177+
reasoning string,
1178+
) (agent.Agent, string) {
1179+
usingBackup := backupAgentName != "" &&
1180+
agent.CanonicalName(a.Name()) == agent.CanonicalName(backupAgentName) &&
1181+
agent.CanonicalName(a.Name()) != agent.CanonicalName(preferredAgent)
1182+
1183+
var model string
1184+
if usingBackup && cliModel == "" {
1185+
model = config.ResolveBackupModelForWorkflow(
1186+
repoPath, cfg, workflow,
1187+
)
1188+
} else {
1189+
model = config.ResolveModelForWorkflow(
1190+
cliModel, repoPath, cfg, workflow, reasoning,
1191+
)
1192+
}
1193+
1194+
if model != "" {
1195+
a = a.WithModel(model)
1196+
}
1197+
return a, model
11581198
}

cmd/roborev/refine_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,140 @@ func TestResolveReasoningWithFast(t *testing.T) {
953953
}
954954
}
955955

956+
// TestApplyModelForAgent_BackupKeepsOwnModel verifies that when the backup
957+
// agent is selected (primary unavailable) and no backup model is configured,
958+
// applyModelForAgent does not apply the primary model to the backup agent.
959+
// Regression test for f7385273.
960+
func TestApplyModelForAgent_BackupKeepsOwnModel(t *testing.T) {
961+
// Make only codex available (not gemini, which will be "primary").
962+
t.Cleanup(testutil.MockExecutableIsolated(t, "codex", 0))
963+
964+
selected, err := selectRefineAgent(
965+
nil, "gemini", agent.ReasoningStandard, "codex",
966+
)
967+
if err != nil {
968+
t.Fatalf("selectRefineAgent: %v", err)
969+
}
970+
if selected.Name() != "codex" {
971+
t.Fatalf("expected backup agent codex, got %s", selected.Name())
972+
}
973+
974+
// Call the real helper with no CLI model and no config.
975+
// The primary would resolve to some default model, but the backup
976+
// agent should NOT inherit it.
977+
result, model := applyModelForAgent(
978+
selected,
979+
"gemini", // preferred (unavailable)
980+
"codex", // backup (selected)
981+
"", // no CLI model
982+
"", // no repo path
983+
nil, // no config
984+
"refine",
985+
"standard",
986+
)
987+
988+
codexAgent, ok := result.(*agent.CodexAgent)
989+
if !ok {
990+
t.Fatalf("expected *CodexAgent, got %T", result)
991+
}
992+
if codexAgent.Model != "" {
993+
t.Errorf(
994+
"backup agent should keep its default model (empty), got %q",
995+
codexAgent.Model,
996+
)
997+
}
998+
if model != "" {
999+
t.Errorf("resolved model should be empty for unconfigured backup, got %q", model)
1000+
}
1001+
}
1002+
1003+
// TestApplyModelForAgent_EmptyModelPreservesAgentDefault verifies that
1004+
// applyModelForAgent does not clear an agent's existing model when the
1005+
// resolved model is empty.
1006+
// Regression test for f7385273.
1007+
func TestApplyModelForAgent_EmptyModelPreservesAgentDefault(t *testing.T) {
1008+
t.Cleanup(testutil.MockExecutable(t, "codex", 0))
1009+
1010+
a, err := agent.Get("codex")
1011+
if err != nil {
1012+
t.Fatalf("agent.Get: %v", err)
1013+
}
1014+
// Pre-set a model on the agent.
1015+
a = a.WithModel("o3")
1016+
1017+
// Agent is the preferred agent (not a backup). No CLI model, no
1018+
// config → resolved model will be empty.
1019+
result, _ := applyModelForAgent(
1020+
a,
1021+
"codex", // preferred
1022+
"", // no backup
1023+
"", // no CLI model
1024+
"", // no repo path
1025+
nil, // no config
1026+
"review",
1027+
"standard",
1028+
)
1029+
1030+
codexAgent, ok := result.(*agent.CodexAgent)
1031+
if !ok {
1032+
t.Fatalf("expected *CodexAgent, got %T", result)
1033+
}
1034+
if codexAgent.Model != "o3" {
1035+
t.Errorf(
1036+
"agent model should remain %q, got %q",
1037+
"o3", codexAgent.Model,
1038+
)
1039+
}
1040+
}
1041+
1042+
// TestApplyModelForAgent_SameAgentPrimaryAndBackup verifies that when
1043+
// primary and backup resolve to the same agent, the agent is treated as
1044+
// the primary (not backup) and gets the workflow model, not the backup model.
1045+
func TestApplyModelForAgent_SameAgentPrimaryAndBackup(t *testing.T) {
1046+
t.Cleanup(testutil.MockExecutable(t, "codex", 0))
1047+
1048+
a, err := agent.Get("codex")
1049+
if err != nil {
1050+
t.Fatalf("agent.Get: %v", err)
1051+
}
1052+
1053+
// Config with distinct primary and backup models so we can tell
1054+
// which resolution path was taken.
1055+
cfg := &config.Config{
1056+
ReviewModel: "primary-model",
1057+
ReviewBackupModel: "backup-model",
1058+
}
1059+
1060+
// Primary and backup are the same agent. The helper should NOT
1061+
// treat this as "using backup" — the CanonicalName comparison
1062+
// against preferredAgent must prevent that.
1063+
result, model := applyModelForAgent(
1064+
a,
1065+
"codex", // preferred (same as backup)
1066+
"codex", // backup (same as preferred)
1067+
"", // no CLI model
1068+
"", // no repo path
1069+
cfg,
1070+
"review",
1071+
"standard",
1072+
)
1073+
1074+
codexAgent, ok := result.(*agent.CodexAgent)
1075+
if !ok {
1076+
t.Fatalf("expected *CodexAgent, got %T", result)
1077+
}
1078+
// Should pick the primary workflow model, not the backup.
1079+
if model != "primary-model" {
1080+
t.Errorf("expected primary model %q, got %q", "primary-model", model)
1081+
}
1082+
if codexAgent.Model != "primary-model" {
1083+
t.Errorf(
1084+
"expected agent model %q, got %q",
1085+
"primary-model", codexAgent.Model,
1086+
)
1087+
}
1088+
}
1089+
9561090
func TestRefineFlagValidation(t *testing.T) {
9571091
tests := []struct {
9581092
name string

cmd/roborev/review.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -379,20 +379,23 @@ func runLocalReview(cmd *cobra.Command, repoPath, gitRef, diffContent, agentName
379379
}
380380

381381
// Resolve agent using workflow-specific resolution (matches daemon behavior)
382-
agentName = config.ResolveAgentForWorkflow(agentName, repoPath, cfg, workflow, reasoning)
382+
preferredAgent := config.ResolveAgentForWorkflow(agentName, repoPath, cfg, workflow, reasoning)
383+
backupAgent := config.ResolveBackupAgentForWorkflow(repoPath, cfg, workflow)
383384

384-
// Get the agent
385-
a, err := agent.GetAvailableWithConfig(agentName, cfg)
385+
// Get the agent (try backup before hardcoded chain)
386+
a, err := agent.GetAvailableWithConfig(preferredAgent, cfg, backupAgent)
386387
if err != nil {
387388
return fmt.Errorf("get agent: %w", err)
388389
}
389390

390-
// Resolve model using workflow-specific resolution (matches daemon behavior)
391-
model = config.ResolveModelForWorkflow(model, repoPath, cfg, workflow, reasoning)
392-
393-
// Configure agent with model and reasoning
391+
// Configure agent with model and reasoning. applyModelForAgent
392+
// handles backup-vs-primary model resolution.
394393
reasoningLevel := agent.ParseReasoningLevel(reasoning)
395-
a = a.WithReasoning(reasoningLevel).WithModel(model)
394+
a = a.WithReasoning(reasoningLevel)
395+
a, model = applyModelForAgent(
396+
a, preferredAgent, backupAgent,
397+
model, repoPath, cfg, workflow, reasoning,
398+
)
396399

397400
// Configure provider for pi agent
398401
if provider != "" {

internal/agent/acp.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,7 +1507,10 @@ func configuredACPAgent(cfg *config.Config) *ACPAgent {
15071507
// It treats cfg.ACP.Name as an alias for "acp" and applies cfg.ACP command/mode/model
15081508
// at resolution time instead of package-init time.
15091509
// It also applies command overrides for other agents (codex, claude, cursor, pi).
1510-
func GetAvailableWithConfig(preferred string, cfg *config.Config) (Agent, error) {
1510+
//
1511+
// Optional backup agent names are tried after the preferred agent but
1512+
// before the hardcoded fallback chain (see GetAvailable).
1513+
func GetAvailableWithConfig(preferred string, cfg *config.Config, backups ...string) (Agent, error) {
15111514
rawPreferred := strings.TrimSpace(preferred)
15121515
preferred = resolveAlias(rawPreferred)
15131516

@@ -1525,11 +1528,11 @@ func GetAvailableWithConfig(preferred string, cfg *config.Config) (Agent, error)
15251528
}
15261529
}
15271530

1528-
// Finally fall back to normal auto-selection.
1529-
return GetAvailable("")
1531+
// Finally fall back to normal auto-selection (with backups).
1532+
return GetAvailable("", backups...)
15301533
}
15311534

1532-
resolved, err := GetAvailable(preferred)
1535+
resolved, err := GetAvailable(preferred, backups...)
15331536
if err != nil {
15341537
return nil, err
15351538
}

internal/agent/acp_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,3 +1191,33 @@ func TestACPNameDoesNotMatchCanonicalRequest(t *testing.T) {
11911191
t.Fatalf("Expected claude-code agent, got %q", resolved.Name())
11921192
}
11931193
}
1194+
1195+
func TestGetAvailableWithConfigPassesBackupsThrough(t *testing.T) {
1196+
// When preferred is unavailable, backups should be tried before the chain.
1197+
fakeBin := t.TempDir()
1198+
binName := "gemini"
1199+
if runtime.GOOS == "windows" {
1200+
binName += ".exe"
1201+
}
1202+
geminiBin := filepath.Join(fakeBin, binName)
1203+
if err := os.WriteFile(geminiBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
1204+
t.Fatalf("create fake gemini binary: %v", err)
1205+
}
1206+
t.Setenv("PATH", fakeBin)
1207+
1208+
originalRegistry := registry
1209+
registry = map[string]Agent{
1210+
"codex": NewCodexAgent("definitely-not-on-path"),
1211+
"gemini": NewGeminiAgent(""),
1212+
}
1213+
t.Cleanup(func() { registry = originalRegistry })
1214+
1215+
cfg := &config.Config{}
1216+
resolved, err := GetAvailableWithConfig("codex", cfg, "gemini")
1217+
if err != nil {
1218+
t.Fatalf("expected fallback, got error: %v", err)
1219+
}
1220+
if resolved.Name() != "gemini" {
1221+
t.Fatalf("expected backup agent 'gemini', got %q", resolved.Name())
1222+
}
1223+
}

0 commit comments

Comments
 (0)