e4bc3be8b1
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
147 lines
5.4 KiB
Go
147 lines
5.4 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
|
|
"github.com/kandev/kandev/internal/agentctl/sessionmodel"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// StartModelPolicy carries the profile's model-selection settings for the
|
|
// no-silent-model-fallback policy applied at session start (and when a
|
|
// context reset re-applies the effective model to a fresh ACP session).
|
|
type StartModelPolicy struct {
|
|
// Model is the configured start model (ACP model ID). Empty means "use
|
|
// the agent's default" — nothing is applied.
|
|
Model string
|
|
// FallbackModel is the optional single model to switch to when Model is
|
|
// unavailable. Ignored when AutoFallback is enabled.
|
|
FallbackModel string
|
|
// AutoFallback restores the legacy best-effort behavior: SetModel
|
|
// failures log and the session continues on the provider default.
|
|
AutoFallback bool
|
|
}
|
|
|
|
// modelApplier abstracts the agentctl surface applyStartModelPolicy needs so
|
|
// tests can script SetModel without a live agentctl server.
|
|
type modelApplier interface {
|
|
SetModel(ctx context.Context, modelID string) error
|
|
}
|
|
|
|
// advertisedModelIDs returns the session's currently advertised model IDs.
|
|
// An empty slice means the agent has not advertised a model list (unknown).
|
|
func advertisedModelIDs(state *CachedModelState) []string {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
ids := make([]string, 0, len(state.Models))
|
|
for _, m := range state.Models {
|
|
ids = append(ids, m.ModelID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// containsModel reports whether ids contains id.
|
|
|
|
func containsModel(ids []string, id string) bool {
|
|
for _, existing := range ids {
|
|
if existing == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// applyFallbackModel applies the policy's fallback model after the start
|
|
// model proved unavailable (gone from the advertised list or rejected by
|
|
// SetModel). Shared by both failure branches so the log line, error
|
|
// wrapping, and return shape stay in sync. reason ("is unavailable" /
|
|
// "failed") distinguishes the failure context in the error text.
|
|
func applyFallbackModel(
|
|
ctx context.Context,
|
|
log *logger.Logger,
|
|
applier modelApplier,
|
|
policy StartModelPolicy,
|
|
reason string,
|
|
) (string, bool, error) {
|
|
if err := applier.SetModel(ctx, policy.FallbackModel); err != nil {
|
|
return "", false, fmt.Errorf(
|
|
"start model %q %s and fallback model %q failed to apply: %w",
|
|
policy.Model, reason, policy.FallbackModel, err)
|
|
}
|
|
log.Info("start model unavailable, using fallback model",
|
|
zap.String("start_model", policy.Model),
|
|
zap.String("fallback_model", policy.FallbackModel))
|
|
return policy.FallbackModel, true, nil
|
|
}
|
|
|
|
// applyStartModelPolicy enforces the no-silent-model-fallback rules when
|
|
// applying the profile's start model to a session:
|
|
//
|
|
// - AutoFallback: legacy best-effort (attempt SetModel; on failure warn
|
|
// and continue on the provider default).
|
|
// - Start model gone (absent from the advertised list, when known):
|
|
// apply FallbackModel when set (returning usingFallback=true), else
|
|
// fail with the actionable model-unavailable message.
|
|
// - Start model present / list unknown: apply it; a method-not-found
|
|
// error means the agent has no model selection — continue silently;
|
|
// any other error fails explicitly.
|
|
//
|
|
// Returns the model actually applied ("" when none was applied / the agent
|
|
// default remains) and whether the fallback model was used.
|
|
func applyStartModelPolicy(
|
|
ctx context.Context,
|
|
log *logger.Logger,
|
|
applier modelApplier,
|
|
state *CachedModelState,
|
|
policy StartModelPolicy,
|
|
) (applied string, usingFallback bool, err error) {
|
|
if policy.Model == "" {
|
|
return "", false, nil
|
|
}
|
|
if policy.AutoFallback {
|
|
if err := applier.SetModel(ctx, policy.Model); err != nil {
|
|
log.Warn("failed to set profile model via ACP (auto-fallback)",
|
|
zap.String("model", policy.Model), zap.Error(err))
|
|
return "", false, nil
|
|
}
|
|
return policy.Model, false, nil
|
|
}
|
|
|
|
advertised := advertisedModelIDs(state)
|
|
if len(advertised) > 0 && !containsModel(advertised, policy.Model) {
|
|
if policy.FallbackModel != "" {
|
|
return applyFallbackModel(ctx, log, applier, policy, "is unavailable")
|
|
}
|
|
return "", false, fmt.Errorf("%s", routingerr.ModelUnavailableMessage(policy.Model))
|
|
}
|
|
|
|
if err := applier.SetModel(ctx, policy.Model); err != nil {
|
|
if sessionmodel.IsMethodNotFound(err) {
|
|
// The agent declares no model-selection support — nothing to
|
|
// enforce; the session continues on the agent default.
|
|
log.Debug("agent does not support model selection, continuing",
|
|
zap.String("model", policy.Model), zap.Error(err))
|
|
return "", false, nil
|
|
}
|
|
// SetModel failed even though the model appeared available (the
|
|
// advertised list may not have arrived yet at session start, or the
|
|
// agent rejected the model itself). The explicit fallback exists
|
|
// exactly for this: switch to it instead of failing — but never
|
|
// fall through to the provider default. A transport or cancellation
|
|
// failure is NOT evidence the model is unavailable — switching
|
|
// models would mask a broken connection, so those fail explicitly
|
|
// like any other non-availability error.
|
|
if policy.FallbackModel != "" &&
|
|
!isTransportDeadErr(err) && !errors.Is(err, context.Canceled) {
|
|
return applyFallbackModel(ctx, log, applier, policy, "failed")
|
|
}
|
|
return "", false, fmt.Errorf("failed to set start model %q: %w", policy.Model, err)
|
|
}
|
|
return policy.Model, false, nil
|
|
}
|