Files
kandev/apps/backend/internal/review/resolver.go
T
2026-08-09 18:36:08 +01:00

252 lines
9.6 KiB
Go

package review
import (
"context"
"errors"
"fmt"
"strings"
)
// ReviewerIdentity is the (agent CLI, model, mode) triple a review pass runs as.
// It is deliberately separate from the task's own agent profile: the point of the
// feature is that a different model can review than implemented.
type ReviewerIdentity struct {
ProfileID string
AgentID string
Model string
Mode string
// Source names where the identity came from, for the run record and logs:
// "agent_profile", "utility_agent", or "user_default".
Source string
}
// Identity sources.
const (
SourceAgentProfile = "agent_profile"
SourceUtilityAgent = "utility_agent"
SourceUserDefault = "user_default"
)
// ReviewerProfile is the subset of an agent profile the resolver needs.
type ReviewerProfile struct {
AgentID string
Model string
Mode string
CLIPassthrough bool
Name string
}
// ProfileLookup resolves an agent profile ID. Returns found=false for an unknown
// profile.
type ProfileLookup interface {
ReviewerProfile(ctx context.Context, profileID string) (profile ReviewerProfile, found bool, err error)
}
// UtilityAgentLookup resolves the built-in `code-review` utility agent, which
// supplies the default reviewer identity for the on-demand path.
type UtilityAgentLookup interface {
// CodeReviewAgent returns the code-review utility agent's agent/model and
// whether it is enabled. found=false means the row is missing entirely.
CodeReviewAgent(ctx context.Context) (agentID, model string, enabled, found bool, err error)
}
type UtilityProfileLookup interface {
CodeReviewAgentProfile(ctx context.Context) (profileID string, enabled, found bool, err error)
}
// DefaultsLookup resolves the user's default utility agent/model pair, used when
// the code-review utility agent leaves the model blank.
type DefaultsLookup interface {
DefaultUtilitySettings(ctx context.Context) (agentID, model string, err error)
}
type DefaultProfileLookup interface {
DefaultUtilityProfileID(ctx context.Context) (string, error)
}
// Resolver picks the reviewer identity for a run.
type Resolver struct {
Profiles ProfileLookup
Utility UtilityAgentLookup
Defaults DefaultsLookup
}
// NewResolver builds a resolver. Any lookup may be nil; a nil lookup simply
// contributes no candidate identity.
func NewResolver(profiles ProfileLookup, utility UtilityAgentLookup, defaults DefaultsLookup) *Resolver {
return &Resolver{Profiles: profiles, Utility: utility, Defaults: defaults}
}
// Resolve returns the reviewer identity for a run.
//
// Precedence:
// 1. an explicit agent profile (the workflow-step path), which is what makes a
// per-step reviewing model possible;
// 2. the enabled built-in `code-review` utility agent;
// 3. the user's default utility agent/model.
//
// The agent/model pair is treated as inseparable: a default model from one
// provider must never be sent to another provider's ACP config, so a blank model
// pulls both halves from the defaults rather than just the model.
//
// Fails closed with ErrAgentUnavailable — never guesses an agent — because a
// wrong provider/model pair produces either a hard provider error or, worse, a
// confident review from a model the user did not choose.
func (r *Resolver) Resolve(ctx context.Context, agentProfileID string) (ReviewerIdentity, error) {
if strings.TrimSpace(agentProfileID) != "" {
return r.resolveFromProfile(ctx, strings.TrimSpace(agentProfileID))
}
identity, err := r.resolveFromUtilityAgent(ctx)
if err == nil {
return identity, nil
}
// Only an absent or disabled code-review agent defers to the user defaults.
// A configured-but-inconsistent one is a hard failure: falling back there
// would quietly review with a provider/model pair the user never chose.
if !errors.Is(err, errDeferToDefaults) {
return ReviewerIdentity{}, err
}
return r.resolveFromDefaults(ctx)
}
// errDeferToDefaults marks the "code-review utility agent is absent or turned
// off" case, where falling back to the user's default agent/model is correct.
var errDeferToDefaults = errors.New("defer to default utility settings")
func (r *Resolver) resolveFromProfile(ctx context.Context, profileID string) (ReviewerIdentity, error) {
if r.Profiles == nil {
return ReviewerIdentity{}, fmt.Errorf("%w: agent profiles are not available", ErrAgentUnavailable)
}
profile, found, err := r.Profiles.ReviewerProfile(ctx, profileID)
if err != nil {
return ReviewerIdentity{}, fmt.Errorf("%w: load agent profile %s: %v", ErrAgentUnavailable, profileID, err)
}
if !found {
return ReviewerIdentity{}, fmt.Errorf("%w: agent profile %s no longer exists", ErrAgentUnavailable, profileID)
}
if profile.CLIPassthrough {
return ReviewerIdentity{}, fmt.Errorf(
"%w: agent profile %q is CLI-passthrough only and cannot run a structured review",
ErrAgentUnavailable, describeProfile(profile, profileID))
}
if profile.AgentID == "" {
return ReviewerIdentity{}, fmt.Errorf("%w: agent profile %q has no agent configured",
ErrAgentUnavailable, describeProfile(profile, profileID))
}
model := profile.Model
if model == "" {
// The profile names an agent but no model; borrow the user's default
// model only when it belongs to the same agent.
model = r.sameAgentDefaultModel(ctx, profile.AgentID)
}
if model == "" {
return ReviewerIdentity{}, fmt.Errorf("%w: agent profile %q has no model configured",
ErrAgentUnavailable, describeProfile(profile, profileID))
}
return ReviewerIdentity{
ProfileID: profileID,
AgentID: profile.AgentID,
Model: model,
Mode: profile.Mode,
Source: SourceAgentProfile,
}, nil
}
func describeProfile(profile ReviewerProfile, profileID string) string {
if profile.Name != "" {
return profile.Name
}
return profileID
}
// sameAgentDefaultModel returns the user's default model only when the default
// belongs to agentID, so a model is never crossed between providers.
func (r *Resolver) sameAgentDefaultModel(ctx context.Context, agentID string) string {
if r.Defaults == nil {
return ""
}
defaultAgent, defaultModel, err := r.Defaults.DefaultUtilitySettings(ctx)
if err != nil || defaultAgent != agentID {
return ""
}
return defaultModel
}
func (r *Resolver) resolveFromUtilityAgent(ctx context.Context) (ReviewerIdentity, error) {
if profileLookup, ok := r.Utility.(UtilityProfileLookup); ok {
profileID, enabled, found, err := profileLookup.CodeReviewAgentProfile(ctx)
if err != nil {
return ReviewerIdentity{}, fmt.Errorf("%w: load code-review utility profile: %v", ErrAgentUnavailable, err)
}
if !found || !enabled || profileID == "" {
return ReviewerIdentity{}, fmt.Errorf("%w: code-review utility profile is not configured", errDeferToDefaults)
}
return ReviewerIdentity{ProfileID: profileID, Source: SourceUtilityAgent}, nil
}
if r.Utility == nil {
return ReviewerIdentity{}, fmt.Errorf("%w: %w: no code-review utility agent is wired",
ErrAgentUnavailable, errDeferToDefaults)
}
agentID, model, enabled, found, err := r.Utility.CodeReviewAgent(ctx)
if err != nil {
// A lookup failure is a real fault, not an unconfigured feature; do not
// mask it by silently reviewing with the defaults.
return ReviewerIdentity{}, fmt.Errorf("%w: load code-review utility agent: %v", ErrAgentUnavailable, err)
}
if !found {
return ReviewerIdentity{}, fmt.Errorf("%w: %w: the code-review utility agent is missing",
ErrAgentUnavailable, errDeferToDefaults)
}
if !enabled {
return ReviewerIdentity{}, fmt.Errorf("%w: %w: the code-review utility agent is disabled",
ErrAgentUnavailable, errDeferToDefaults)
}
// A configured utility agent with a blank model means "use my defaults";
// take the agent and model together so the pair stays consistent.
if model == "" {
defaults, defErr := r.resolveFromDefaults(ctx)
if defErr != nil {
return ReviewerIdentity{}, defErr
}
if agentID == "" {
agentID = defaults.AgentID
}
if agentID != defaults.AgentID {
return ReviewerIdentity{}, fmt.Errorf(
"%w: the code-review utility agent uses %s but the default model belongs to %s; set a model on the code-review agent in Settings > Utility Agents",
ErrAgentUnavailable, agentID, defaults.AgentID)
}
return ReviewerIdentity{AgentID: agentID, Model: defaults.Model, Source: SourceUtilityAgent}, nil
}
if agentID == "" {
return ReviewerIdentity{}, fmt.Errorf("%w: the code-review utility agent has no agent configured", ErrAgentUnavailable)
}
return ReviewerIdentity{AgentID: agentID, Model: model, Source: SourceUtilityAgent}, nil
}
func (r *Resolver) resolveFromDefaults(ctx context.Context) (ReviewerIdentity, error) {
if profileLookup, ok := r.Defaults.(DefaultProfileLookup); ok {
profileID, err := profileLookup.DefaultUtilityProfileID(ctx)
if err != nil {
return ReviewerIdentity{}, fmt.Errorf("%w: load default utility profile: %v", ErrAgentUnavailable, err)
}
if profileID == "" {
return ReviewerIdentity{}, fmt.Errorf("%w: no default utility profile configured", ErrAgentUnavailable)
}
return ReviewerIdentity{ProfileID: profileID, Source: SourceUserDefault}, nil
}
if r.Defaults == nil {
return ReviewerIdentity{}, fmt.Errorf("%w: no default utility agent is configured", ErrAgentUnavailable)
}
agentID, model, err := r.Defaults.DefaultUtilitySettings(ctx)
if err != nil {
return ReviewerIdentity{}, fmt.Errorf("%w: load default utility settings: %v", ErrAgentUnavailable, err)
}
if agentID == "" || model == "" {
return ReviewerIdentity{}, fmt.Errorf(
"%w: no inference-capable agent and model are configured; set one in Settings > Utility Agents",
ErrAgentUnavailable)
}
return ReviewerIdentity{AgentID: agentID, Model: model, Source: SourceUserDefault}, nil
}