PR #3473
Sections
Review

fix(agents): enforce exact profile model identity

main ← feature/fix-pr-lane-profile-bt7 43 files +842 −617 PR #3473 ↗

This PR enforces exact model identity for agent profiles and removes inferred variation fallback, so a session fails before inference when the executor does not advertise the configured model.

Why this change

A profile can request opus while the executor advertises only opus[1m]. The previous code inferred the variation and started the session on a different model. This changes the user's chosen identity and cost without consent.

What it does

Architecture, end to end

The executor catalog owns the decision. The policy runs at launch, reset, and rebind. Exact profiles fail closed. Only explicit fallback or auto fallback can authorize an alternate model.

flowchart LR
  Profile[Agent profile model] --> Policy[applyStartModelPolicy]
  Catalog[Executor ACP catalog] --> Policy
  Policy --> Check{Exact model advertised?}
  Check -- yes --> Apply[SetModel exact]
  Check -- no --> Fallback{Explicit fallback advertised?}
  Fallback -- yes --> ApplyFB[SetModel fallback + warning]
  Fallback -- no --> Auto{auto_fallback?}
  Auto -- yes --> Default[Continue on provider default + warning]
  Auto -- no --> Fail[Fail before prompt]
  Apply --> Launch[Session start / reset / rebind]
  ApplyFB --> Launch
  Default --> Launch
  Fail --> Error[Sanitized error with reason]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func applyStartModelPolicy(ctx context.Context, log *logger.Logger, applier modelApplier, state *CachedModelState, policy StartModelPolicy) (ModelSelectionDecision, error)
Click for details →

The policy now requires an exact advertised match and never infers a bracketed variation.

Policy
func applyStartModelPolicy(
	ctx context.Context,
	log *logger.Logger,
	applier modelApplier,
	state *CachedModelState,
	policy StartModelPolicy,
) (ModelSelectionDecision, error) {
	if policy.Model == "" {
		return ModelSelectionDecision{Outcome: ModelSelectionOutcomeNone}, nil
	}
	advertised := advertisedModelIDs(state)
	if len(advertised) == 0 {
		return unavailableStartModel(state, policy, ModelSelectionReasonCatalogEmpty)
	}
	if !containsModel(advertised, policy.Model) {
		if !policy.AutoFallback && policy.FallbackModel != "" && containsModel(advertised, policy.FallbackModel) {
			return applyAdvertisedFallback(ctx, log, applier, state, policy, decision)
		}
		reason := ModelSelectionReasonRequestedNotAdvertised
		if !policy.AutoFallback && policy.FallbackModel != "" {
			reason = ModelSelectionReasonFallbackNotAdvertised
		}
		return unavailableStartModel(state, policy, reason)
	}
	decision.SetModelCalled = true
	if err := applier.SetModel(ctx, policy.Model); err != nil {
		if sessionmodel.IsMethodNotFound(err) {
			unsupported, unavailableErr := unavailableStartModel(state, policy, ModelSelectionReasonSelectionUnsupported)
			unsupported.SetModelCalled = true
			return unsupported, unavailableErr
		}
		if policy.AutoFallback {
			decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionFailedAutoFallback)
			decision.SetModelCalled = true
			return decision, nil
		}
		return decision, fmt.Errorf("failed to set start model %q: %w", policy.Model, err)
	}
	decision.EffectiveModel = policy.Model
	decision.Outcome = ModelSelectionOutcomeApplied
	return decision, nil
}
Outcomes
const (
	ModelSelectionOutcomeNone             ModelSelectionOutcome = ""
	ModelSelectionOutcomeApplied          ModelSelectionOutcome = "applied"
	ModelSelectionOutcomeExplicitFallback ModelSelectionOutcome = "explicit_fallback"
	ModelSelectionOutcomeProviderDefault  ModelSelectionOutcome = "provider_default"
)
const (
	ModelSelectionReasonRequestedNotAdvertised      = "requested_not_advertised"
	ModelSelectionReasonFallbackNotAdvertised       = "fallback_not_advertised"
	ModelSelectionReasonCatalogEmpty                = "catalog_empty"
	ModelSelectionReasonSelectionUnsupported        = "selection_unsupported"
	ModelSelectionReasonSelectionFailedAutoFallback = "selection_failed_auto_fallback"
)
func (m *Manager) reapplySessionModelAfterResetWithClient(ctx context.Context, execution *AgentExecution, client *agentctlclient.Client, newSessionID, modelID string) error
Click for details →

A fresh ACP session never continues on the provider default after losing an exact model. The reset path returns the policy error and marks the execution failed.

Diff
func (m *Manager) reapplySessionModelAfterReset(
	ctx context.Context,
	execution *AgentExecution,
	newSessionID, modelID string,
) error {
	client, releaseClient := execution.AcquireAgentCtlClient()
	defer releaseClient()
	return m.reapplySessionModelAfterResetWithClient(ctx, execution, client, newSessionID, modelID)
}

func (m *Manager) reapplySessionModelAfterResetWithClient(
	ctx context.Context,
	execution *AgentExecution,
	client *agentctlclient.Client,
	newSessionID, modelID string,
) error {
	if client == nil || modelID == "" {
		return nil
	}
	policy := m.resolveStartModelPolicy(ctx, execution.AgentProfileID)
	policy.Model = modelID
	decision, err := applyStartModelPolicy(
		ctx, m.logger, client, execution.GetModelState(), policy,
	)
	if err != nil {
		m.logger.Warn("failed to re-apply session model after context reset",
			zap.String("execution_id", execution.ID),
			zap.String("model", modelID),
			zap.Error(err))
		return err
	}
	if decision.Warning && m.sessionManager != nil {
		m.sessionManager.publishModelSelectionWarningEvent(execution, newSessionID, decision)
	}
	if decision.EffectiveModel != "" &&
		(decision.Outcome == ModelSelectionOutcomeApplied ||
			decision.Outcome == ModelSelectionOutcomeExplicitFallback ||
			decision.Outcome == ModelSelectionOutcomeUniqueVariation) {
			decision.Outcome == ModelSelectionOutcomeExplicitFallback) {
		m.logger.Info("re-applied session model after context reset",
			zap.String("execution_id", execution.ID),
			zap.String("session_id", execution.SessionID),
			zap.String("new_acp_session_id", newSessionID),
			zap.String("model", decision.EffectiveModel),
			zap.Bool("using_fallback", decision.Outcome == ModelSelectionOutcomeExplicitFallback))
	}
	return nil
}
Tests now expect rejection
func TestReapplySessionModel_RejectsUnadvertisedExactModel(t *testing.T) {
	mgr := newTestManager(t)
	mock := newRestartMockAgentctlServer(t, false, false)
	client := createTestClient(t, mock.server.URL)
	exec := runtimeConfigResetExecution(client, true)
	exec.SetModelState(&CachedModelState{
		CurrentModelID: "provider-default",
		Models:         []streams.SessionModelInfo{{ModelID: "opus[1m]"}},
	})
	err := mgr.reapplySessionModelAfterReset(ctx, exec, "reset-session", "opus")
	require.ErrorContains(t, err, "requested_not_advertised")
	require.Empty(t, mock.getSetModelIDs())
}
Launch-time exact enforcement in session.goapps/backend/internal/agent/runtime/lifecycle/session.go ↗
func (sm *SessionManager) applyStartModelPolicyToEffectiveModel(ctx context.Context, execution *AgentExecution, acpSessionID string, profileModel, runtimeModel string, startModelPolicy StartModelPolicy) struct
Click for details →

The effective model is decided before any layer applies it. A strict unavailable model fails the launch before a prompt or tool call.

Effective model
func (sm *SessionManager) applyStartModelPolicyToEffectiveModel(
	ctx context.Context,
	execution *AgentExecution,
	acpSessionID string,
	profileModel, runtimeModel string,
	startModelPolicy StartModelPolicy,
) (effective struct {
	model        string
	appliedModel string
	handled      bool
	decision     ModelSelectionDecision
	err          error
}) {
	effective.model = profileModel
	if runtimeModel != "" {
		effective.model = runtimeModel
	}
	client, releaseClient := execution.AcquireAgentCtlClient()
	defer releaseClient()
	if effective.model == "" || client == nil {
		return effective
	}
	effective.handled = true
	startModelPolicy.Model = effective.model
	decision, policyErr := applyStartModelPolicy(
		ctx, sm.logger, client,
		execution.GetModelState(), startModelPolicy,
	)
	if policyErr != nil {
		sm.logger.Error("start model unavailable, failing session start",
			zap.String("execution_id", execution.ID),
			zap.Error(policyErr))
		effective.err = policyErr
		return effective
	}
	effective.decision = decision
	if decision.Outcome == ModelSelectionOutcomeApplied ||
		decision.Outcome == ModelSelectionOutcomeExplicitFallback {
		effective.appliedModel = decision.EffectiveModel
	}
	if decision.Outcome == ModelSelectionOutcomeExplicitFallback {
		effective.model = decision.EffectiveModel
	}
	return effective
}
Caller neutralizes runtime override
effectiveModel := sm.applyStartModelPolicyToEffectiveModel(
	ctx, execution, result.SessionID, profileModel, runtimeModel, startModelPolicy,
)
if effectiveModel.err != nil {
	return effectiveModel.err
}
if effectiveModel.decision.Warning {
	sm.publishModelSelectionWarningEvent(execution, result.SessionID, effectiveModel.decision)
}
profileModel = effectiveModel.model
runtimeModel = ""
execution.setSessionInitialized(true)
Frontend removes variation inferenceapps/web/components/task-create-dialog-options.tsx ↗
export function useAgentProfileOptions(agentProfiles: AgentProfileOption[], context?: AgentProfileRecentUseContext): OptionItem[]
Click for details →

The host probe is now an editing hint only. A missing model shows a warning but never removes the profile or infers a variation.

Host probe warning
const advertised = advertisedModelIDs(availableAgents, profile.agent_name);
const startModelGone = Boolean(
  profile.model && advertised.length > 0 && !advertised.includes(profile.model),
);
let modelProbeNote: string | undefined;
if (startModelGone) {
  modelProbeNote = t("settings:profileStartModelNotAdvertisedOnHost", {
    model: profile.model,
  });
}
// The host-utility probe is an editing hint only. The selected
// executor owns the launch-time model catalog, so a host-only mismatch
// must never remove a profile from the task selector.
Deleted helper
// Deleted: apps/web/lib/model-variation.ts
// Deleted: apps/web/lib/model-variation.test.ts
// Previous helper inferred opus -> opus[1m] when one variation was advertised.
// Exact identity now requires the user to select the advertised ID explicitly.
function modelSelectionReasonLabel(reason: string | undefined): string
Click for details →

The UI keeps a compatibility path for the retired unique_variation reason and shows exact mismatch reasons with remediation.

Reason label
function modelSelectionReasonLabel(reason: string | undefined): string {
  const keyByReason: Record<string, string> = {
    requested_not_advertised: "task:modelSelectionReasonRequestedNotAdvertised",
    fallback_not_advertised: "task:modelSelectionReasonFallbackNotAdvertised",
    catalog_empty: "task:modelSelectionReasonCatalogEmpty",
    selection_unsupported: "task:modelSelectionReasonUnsupported",
    selection_failed_auto_fallback: "task:modelSelectionReasonAutoFallback",
  };
  if (reason === "unique_variation_applied") {
    return t("task:modelSelectionReasonUniqueVariation");
  }
  return reason
    ? t(keyByReason[reason] ?? "task:modelSelectionReasonUnknown", { reason })
    : t(UNKNOWN_TASK_KEY);
}
ADR
# ADR-2026-09-06-exact-profile-model-identity: Enforce Exact Profile Model Identity
**Status:** accepted
A profile with a non-empty model, auto_fallback = false, and no fallback_model is exact.
Its configured model must be advertised and applied before inference.
If that cannot be attested, the session fails before a prompt, tool call, or agent output.
Kandev never sends SetModel for an unadvertised model.
Read the changes as a list

Exact model policy in start_model.go

apps/backend/internal/agent/runtime/lifecycle/start_model.go

The policy now requires an exact advertised match and never infers a bracketed variation.

Policy
func applyStartModelPolicy(
	ctx context.Context,
	log *logger.Logger,
	applier modelApplier,
	state *CachedModelState,
	policy StartModelPolicy,
) (ModelSelectionDecision, error) {
	if policy.Model == "" {
		return ModelSelectionDecision{Outcome: ModelSelectionOutcomeNone}, nil
	}
	advertised := advertisedModelIDs(state)
	if len(advertised) == 0 {
		return unavailableStartModel(state, policy, ModelSelectionReasonCatalogEmpty)
	}
	if !containsModel(advertised, policy.Model) {
		if !policy.AutoFallback && policy.FallbackModel != "" && containsModel(advertised, policy.FallbackModel) {
			return applyAdvertisedFallback(ctx, log, applier, state, policy, decision)
		}
		reason := ModelSelectionReasonRequestedNotAdvertised
		if !policy.AutoFallback && policy.FallbackModel != "" {
			reason = ModelSelectionReasonFallbackNotAdvertised
		}
		return unavailableStartModel(state, policy, reason)
	}
	decision.SetModelCalled = true
	if err := applier.SetModel(ctx, policy.Model); err != nil {
		if sessionmodel.IsMethodNotFound(err) {
			unsupported, unavailableErr := unavailableStartModel(state, policy, ModelSelectionReasonSelectionUnsupported)
			unsupported.SetModelCalled = true
			return unsupported, unavailableErr
		}
		if policy.AutoFallback {
			decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionFailedAutoFallback)
			decision.SetModelCalled = true
			return decision, nil
		}
		return decision, fmt.Errorf("failed to set start model %q: %w", policy.Model, err)
	}
	decision.EffectiveModel = policy.Model
	decision.Outcome = ModelSelectionOutcomeApplied
	return decision, nil
}
Outcomes
const (
	ModelSelectionOutcomeNone             ModelSelectionOutcome = ""
	ModelSelectionOutcomeApplied          ModelSelectionOutcome = "applied"
	ModelSelectionOutcomeExplicitFallback ModelSelectionOutcome = "explicit_fallback"
	ModelSelectionOutcomeProviderDefault  ModelSelectionOutcome = "provider_default"
)
const (
	ModelSelectionReasonRequestedNotAdvertised      = "requested_not_advertised"
	ModelSelectionReasonFallbackNotAdvertised       = "fallback_not_advertised"
	ModelSelectionReasonCatalogEmpty                = "catalog_empty"
	ModelSelectionReasonSelectionUnsupported        = "selection_unsupported"
	ModelSelectionReasonSelectionFailedAutoFallback = "selection_failed_auto_fallback"
)

Reset and rebind now fail closed

apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go

A fresh ACP session never continues on the provider default after losing an exact model. The reset path returns the policy error and marks the execution failed.

Diff
func (m *Manager) reapplySessionModelAfterReset(
	ctx context.Context,
	execution *AgentExecution,
	newSessionID, modelID string,
) error {
	client, releaseClient := execution.AcquireAgentCtlClient()
	defer releaseClient()
	return m.reapplySessionModelAfterResetWithClient(ctx, execution, client, newSessionID, modelID)
}

func (m *Manager) reapplySessionModelAfterResetWithClient(
	ctx context.Context,
	execution *AgentExecution,
	client *agentctlclient.Client,
	newSessionID, modelID string,
) error {
	if client == nil || modelID == "" {
		return nil
	}
	policy := m.resolveStartModelPolicy(ctx, execution.AgentProfileID)
	policy.Model = modelID
	decision, err := applyStartModelPolicy(
		ctx, m.logger, client, execution.GetModelState(), policy,
	)
	if err != nil {
		m.logger.Warn("failed to re-apply session model after context reset",
			zap.String("execution_id", execution.ID),
			zap.String("model", modelID),
			zap.Error(err))
		return err
	}
	if decision.Warning && m.sessionManager != nil {
		m.sessionManager.publishModelSelectionWarningEvent(execution, newSessionID, decision)
	}
	if decision.EffectiveModel != "" &&
		(decision.Outcome == ModelSelectionOutcomeApplied ||
			decision.Outcome == ModelSelectionOutcomeExplicitFallback ||
			decision.Outcome == ModelSelectionOutcomeUniqueVariation) {
			decision.Outcome == ModelSelectionOutcomeExplicitFallback) {
		m.logger.Info("re-applied session model after context reset",
			zap.String("execution_id", execution.ID),
			zap.String("session_id", execution.SessionID),
			zap.String("new_acp_session_id", newSessionID),
			zap.String("model", decision.EffectiveModel),
			zap.Bool("using_fallback", decision.Outcome == ModelSelectionOutcomeExplicitFallback))
	}
	return nil
}
Tests now expect rejection
func TestReapplySessionModel_RejectsUnadvertisedExactModel(t *testing.T) {
	mgr := newTestManager(t)
	mock := newRestartMockAgentctlServer(t, false, false)
	client := createTestClient(t, mock.server.URL)
	exec := runtimeConfigResetExecution(client, true)
	exec.SetModelState(&CachedModelState{
		CurrentModelID: "provider-default",
		Models:         []streams.SessionModelInfo{{ModelID: "opus[1m]"}},
	})
	err := mgr.reapplySessionModelAfterReset(ctx, exec, "reset-session", "opus")
	require.ErrorContains(t, err, "requested_not_advertised")
	require.Empty(t, mock.getSetModelIDs())
}

Launch-time exact enforcement in session.go

apps/backend/internal/agent/runtime/lifecycle/session.go

The effective model is decided before any layer applies it. A strict unavailable model fails the launch before a prompt or tool call.

Effective model
func (sm *SessionManager) applyStartModelPolicyToEffectiveModel(
	ctx context.Context,
	execution *AgentExecution,
	acpSessionID string,
	profileModel, runtimeModel string,
	startModelPolicy StartModelPolicy,
) (effective struct {
	model        string
	appliedModel string
	handled      bool
	decision     ModelSelectionDecision
	err          error
}) {
	effective.model = profileModel
	if runtimeModel != "" {
		effective.model = runtimeModel
	}
	client, releaseClient := execution.AcquireAgentCtlClient()
	defer releaseClient()
	if effective.model == "" || client == nil {
		return effective
	}
	effective.handled = true
	startModelPolicy.Model = effective.model
	decision, policyErr := applyStartModelPolicy(
		ctx, sm.logger, client,
		execution.GetModelState(), startModelPolicy,
	)
	if policyErr != nil {
		sm.logger.Error("start model unavailable, failing session start",
			zap.String("execution_id", execution.ID),
			zap.Error(policyErr))
		effective.err = policyErr
		return effective
	}
	effective.decision = decision
	if decision.Outcome == ModelSelectionOutcomeApplied ||
		decision.Outcome == ModelSelectionOutcomeExplicitFallback {
		effective.appliedModel = decision.EffectiveModel
	}
	if decision.Outcome == ModelSelectionOutcomeExplicitFallback {
		effective.model = decision.EffectiveModel
	}
	return effective
}
Caller neutralizes runtime override
effectiveModel := sm.applyStartModelPolicyToEffectiveModel(
	ctx, execution, result.SessionID, profileModel, runtimeModel, startModelPolicy,
)
if effectiveModel.err != nil {
	return effectiveModel.err
}
if effectiveModel.decision.Warning {
	sm.publishModelSelectionWarningEvent(execution, result.SessionID, effectiveModel.decision)
}
profileModel = effectiveModel.model
runtimeModel = ""
execution.setSessionInitialized(true)

Frontend removes variation inference

apps/web/components/task-create-dialog-options.tsx

The host probe is now an editing hint only. A missing model shows a warning but never removes the profile or infers a variation.

Host probe warning
const advertised = advertisedModelIDs(availableAgents, profile.agent_name);
const startModelGone = Boolean(
  profile.model && advertised.length > 0 && !advertised.includes(profile.model),
);
let modelProbeNote: string | undefined;
if (startModelGone) {
  modelProbeNote = t("settings:profileStartModelNotAdvertisedOnHost", {
    model: profile.model,
  });
}
// The host-utility probe is an editing hint only. The selected
// executor owns the launch-time model catalog, so a host-only mismatch
// must never remove a profile from the task selector.
Deleted helper
// Deleted: apps/web/lib/model-variation.ts
// Deleted: apps/web/lib/model-variation.test.ts
// Previous helper inferred opus -> opus[1m] when one variation was advertised.
// Exact identity now requires the user to select the advertised ID explicitly.

Warning rendering and docs

apps/web/components/task/chat/messages/status-message.tsx

The UI keeps a compatibility path for the retired unique_variation reason and shows exact mismatch reasons with remediation.

Reason label
function modelSelectionReasonLabel(reason: string | undefined): string {
  const keyByReason: Record<string, string> = {
    requested_not_advertised: "task:modelSelectionReasonRequestedNotAdvertised",
    fallback_not_advertised: "task:modelSelectionReasonFallbackNotAdvertised",
    catalog_empty: "task:modelSelectionReasonCatalogEmpty",
    selection_unsupported: "task:modelSelectionReasonUnsupported",
    selection_failed_auto_fallback: "task:modelSelectionReasonAutoFallback",
  };
  if (reason === "unique_variation_applied") {
    return t("task:modelSelectionReasonUniqueVariation");
  }
  return reason
    ? t(keyByReason[reason] ?? "task:modelSelectionReasonUnknown", { reason })
    : t(UNKNOWN_TASK_KEY);
}
ADR
# ADR-2026-09-06-exact-profile-model-identity: Enforce Exact Profile Model Identity
**Status:** accepted
A profile with a non-empty model, auto_fallback = false, and no fallback_model is exact.
Its configured model must be advertised and applied before inference.
If that cannot be attested, the session fails before a prompt, tool call, or agent output.
Kandev never sends SetModel for an unadvertised model.

Data and storage

Model selection uses exact identity. The decision is persisted as a warning when an authorized fallback or auto fallback is used.

FieldTypeNotes
ModelSelectionDecision.RequestedModelstringprofile model requested
ModelSelectionDecision.EffectiveModelstringmodel actually applied or provider default
ModelSelectionDecision.Outcomeenumapplied, explicit_fallback, provider_default, none
ModelSelectionDecision.Reasonstringrequested_not_advertised, catalog_empty, selection_unsupported
ModelSelectionDecision.Warningbooltrue when UI must show a durable warning
StartModelPolicy.AutoFallbackbooltrue authorizes provider default continuation
StartModelPolicy.FallbackModelstringsingle explicit fallback, used only when advertised

Risk

7 / 10 High
1 low5 medium10 high

Why this score

  • Launch, reset, and rebind now fail closed for exact profiles when the model is absent, which changes behavior for existing profiles that relied on variation inference.
  • The change touches the critical session start path and the executor catalog gate, so a regression blocks task start.
  • Tests cover exact, fallback, and auto fallback paths, but the breaking change needs manual verification of profile editing and warning display.

Trade-offs and review notes

Where to look first

  1. Check start_model.go: exact match, no variation inference, and correct reason for fallback_not_advertised.
  2. Check manager_interaction.go: reset and rebind return the policy error and do not send SetModel for an unadvertised model.
  3. Check session.go: effective model handling neutralizes the runtime override after the policy decides.
  4. Check frontend: gone models stay visible and disabled, host probe never blocks profile selection.
  5. Check status-message.tsx: retired unique_variation reason still renders for old persisted warnings.