diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index 9937d6c8ca..17e07ee858 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -544,8 +544,7 @@ func (m *Manager) reapplySessionModelAfterReset( } 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), diff --git a/apps/backend/internal/agent/runtime/lifecycle/session.go b/apps/backend/internal/agent/runtime/lifecycle/session.go index 210a09c600..fb89a7f757 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session.go @@ -513,12 +513,10 @@ func (sm *SessionManager) applyStartModelPolicyToEffectiveModel( } effective.decision = decision if decision.Outcome == ModelSelectionOutcomeApplied || - decision.Outcome == ModelSelectionOutcomeExplicitFallback || - decision.Outcome == ModelSelectionOutcomeUniqueVariation { + decision.Outcome == ModelSelectionOutcomeExplicitFallback { effective.appliedModel = decision.EffectiveModel } - if decision.Outcome == ModelSelectionOutcomeExplicitFallback || - decision.Outcome == ModelSelectionOutcomeUniqueVariation { + if decision.Outcome == ModelSelectionOutcomeExplicitFallback { effective.model = decision.EffectiveModel } return effective diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_model_selection_warning_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_model_selection_warning_test.go deleted file mode 100644 index 0c10f33782..0000000000 --- a/apps/backend/internal/agent/runtime/lifecycle/session_model_selection_warning_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package lifecycle - -import ( - "context" - "testing" - "time" - - "github.com/kandev/kandev/internal/agent/agents" - agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/pkg/agent" -) - -func TestInitializeAndPrompt_UniqueVariationPublishesProviderNeutralWarning(t *testing.T) { - mock := newMockAgentServer(t) - defer mock.Close() - - log := newSessionTestLogger() - stopCh := newTestStopCh(t) - sm := NewSessionManager(log, stopCh) - streamMgr := NewStreamManager(log, StreamCallbacks{ - OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {}, - }, nil, stopCh) - cleanupStreamManager(t, stopCh, streamMgr) - eventBus := &MockEventBusWithTracking{} - sm.SetDependencies(NewEventPublisher(eventBus, log), streamMgr, nil, nil) - - client := createTestClient(t, mock.server.URL) - defer client.Close() - execution := &AgentExecution{ - ID: "exec-unique-variation", - TaskID: "task-1", - SessionID: "session-1", - WorkspacePath: "/workspace", - agentctl: client, - promptDoneCh: make(chan PromptCompletionSignal, 1), - } - execution.SetModelState(modelState("opus[1m]")) - agentConfig := &testAgent{ - id: "test-agent", - enabled: true, - runtimeConfig: &agents.RuntimeConfig{ - Cmd: agents.NewCommand("test-agent"), - Protocol: agent.ProtocolACP, - SessionConfig: agents.SessionConfig{}, - ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour}, - }, - } - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, - func(executionID string) error { return nil }, - StartModelPolicy{Model: "opus"}, "plan", nil) - if err != nil { - t.Fatalf("InitializeAndPrompt failed: %v", err) - } - - setModelCalls := 0 - for _, action := range mock.getActionLog() { - if action == "agent.session.set_model" { - setModelCalls++ - } - } - if setModelCalls != 1 { - t.Fatalf("set_model calls = %d, want exactly 1", setModelCalls) - } - - var warning *streams.ModelSelectionWarning - legacyFallbackEvents := 0 - for _, event := range eventBus.getStreamEvents() { - if event.Data == nil { - continue - } - if event.Data.ModelSelectionWarning != nil { - warning = event.Data.ModelSelectionWarning - } - if event.Data.Type == streams.EventTypeSessionModelFallback { - legacyFallbackEvents++ - } - } - if warning == nil { - t.Fatal("expected a model-selection warning event") - } - if warning.RequestedModel != "opus" { - t.Errorf("warning requested model = %q, want opus", warning.RequestedModel) - } - if warning.EffectiveModel != "opus[1m]" { - t.Errorf("warning effective model = %q, want opus[1m]", warning.EffectiveModel) - } - if warning.FallbackModel != "" { - t.Errorf("warning fallback model = %q, want empty", warning.FallbackModel) - } - if warning.Reason != ModelSelectionReasonUniqueVariationApplied { - t.Errorf("warning reason = %q, want %q", warning.Reason, ModelSelectionReasonUniqueVariationApplied) - } - if legacyFallbackEvents != 0 { - t.Fatalf("legacy fallback events = %d, want zero for unique variation", legacyFallbackEvents) - } -} diff --git a/docs/specs/agents/requirements/no-silent-model-fallback.md b/docs/specs/agents/requirements/no-silent-model-fallback.md index ce75e76b04..14eebc2929 100644 --- a/docs/specs/agents/requirements/no-silent-model-fallback.md +++ b/docs/specs/agents/requirements/no-silent-model-fallback.md @@ -30,6 +30,16 @@ must stop the launch before inference rather than silently run another model. `auto_fallback=true`, provider-default continuation remains explicitly authorized and visible. +### REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002: Resolve one advertised model variation + +**Status:** Superseded by the exact-profile amendment to +`REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001`. An inferred variation is not an +exact model identity and therefore cannot authorize inference. + +- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.1:** This superseded requirement + has no active runtime behavior; model selection follows + `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001`. + ## System design The migrated technical source is split into [part 1](../system-design/no-silent-model-fallback-01.md), [part 2](../system-design/no-silent-model-fallback-02.md). diff --git a/docs/specs/agents/system-design/no-silent-model-fallback-01.md b/docs/specs/agents/system-design/no-silent-model-fallback-01.md index 9756a0f0b0..3df8aaf50a 100644 --- a/docs/specs/agents/system-design/no-silent-model-fallback-01.md +++ b/docs/specs/agents/system-design/no-silent-model-fallback-01.md @@ -155,120 +155,9 @@ The executor ACP catalog is authoritative for launch. Exact-profile model identity is enforced by [ADR-2026-09-06](../../../decisions/2026-09-06-exact-profile-model-identity.md). The host probe remains an editing hint and does not block profile selection. -## Unique model-variation resolution - -### Matching contract - -ACP model IDs remain opaque and case-sensitive. Kandev recognizes only this -structural relationship: - -```text -[] -``` - -The requested ID must contain no `[` or `]` character. The advertised ID must -start with the complete requested ID followed by `[`, end with `]`, and contain -a non-empty variant with no bracket character. Kandev does not trim, parse, or -rank the variant. - -Examples: - -| Requested | Advertised candidates | Result | -| --- | --- | --- | -| `opus` | `opus[1m]` | Apply `opus[1m]`. | -| `opus` | `opus[270k]`, `opus[1m, fast]` | Do not infer. | -| `opus[1m]` | `opus[270k]` | Do not infer. | -| `opus` | `myopus[1m]`, `opus-extra[1m]` | Do not infer. | -| `opus` | `opus[]`, `opus[1m][fast]` | Do not infer. | - -Duplicate catalog rows with the same model ID count as one distinct candidate. -This rule keeps the outcome stable when an agent repeats a catalog entry. - -### Resolution order - -For profiles with `auto_fallback = false`, the lifecycle applies this order -after ACP initialization: - -1. Apply the exact requested model when advertised. -2. Apply the configured explicit fallback when advertised. -3. Apply the requested model's unique advertised variation when one exists. -4. Otherwise, make no model-selection call and keep the provider current or - default model. - -An unadvertised explicit fallback does not block step 3. More than one distinct -variation blocks inference even when one candidate appears first or is the -provider current model. - -When `auto_fallback = true`, the legacy path takes precedence when the -requested model is absent: Kandev ignores the explicit fallback and does not -infer a variation. It makes no model-selection call and continues on the -provider current or default model with a warning. Apply errors for an -advertised model remain best-effort in that mode. - -`applyStartModelPolicy` remains the single runtime owner. A pure helper returns -the unique candidate or no candidate. Initial launch, context reset, and -workspace rebind continue to call the same policy. - -### Decision and warning contract - -The typed decision adds the outcome `unique_variation` and the stable reason -`unique_variation_applied`. `EffectiveModel` contains the advertised variation. -`FallbackModel` remains reserved for a configured explicit fallback and is not -overloaded with an inferred value. - -Kandev calls `SetModel` only with the candidate's exact advertised ID. A failed -call follows the existing advertised-model rule: fail the operation unless -legacy `auto_fallback` permits provider-default continuation. A -method-not-supported response also follows the current provider-default path. - -The persisted `model_selection_warning` event uses the new reason and existing -requested and effective model fields. The profile remains unchanged. The -decision ID continues to include the requested model, reason, and effective -model, so the warning stays idempotent for one session decision. - -### Host advisory and mobile behavior - -The frontend uses a small pure helper with the same case-sensitive matching -table. This calculation is advisory because the host catalog can differ from -the selected executor catalog. - -When the host advertises one variation, the task profile option remains -selectable and identifies the possible target in its existing amber advisory. -The profile editor keeps the saved bare ID visible and describes the unique -host variation instead of reporting that the model is simply gone. Selecting -the advertised variation remains an explicit way to update the profile. - -When the host advertises zero or multiple variations, the current missing-model -presentation remains. The runtime can still reach a different decision from -the selected executor catalog. - -The task-create advisory keeps its existing desktop tooltip and coarse-pointer -drawer. The profile editor uses its existing responsive selector. The durable -chat warning uses the existing status-message layout on desktop and mobile. -This change adds no nested scroll area or hover-only information. - -### Tests and operational evidence - -Backend table tests cover exact precedence, explicit-fallback precedence, -one variation, duplicate rows, multiple variations, bracketed requests, -case differences, malformed IDs, method-not-supported, and apply errors. -Existing launch, reset, rebind, warning persistence, and reload tests cover the -new decision outcome. - -Frontend unit tests mirror the matching table and cover the task picker, -profile editor, and localized status warning. Existing desktop and mobile model -mismatch E2E flows add the unique and ambiguous catalog cases. - -Structured lifecycle logs record the requested model, effective model, outcome, -and distinct candidate count. They do not record credentials or configuration -file contents. - -No database migration is required. - ## Related decisions - [Let the Executor Own Model Selection](../../../decisions/2026-08-15-executor-authoritative-model-selection.md) -- [Resolve Only One Advertised Model Variation](../../../decisions/2026-09-07-unique-model-variation-resolution.md) ## Backend Changes diff --git a/docs/specs/agents/system-design/no-silent-model-fallback-02.md b/docs/specs/agents/system-design/no-silent-model-fallback-02.md index 3ecc02c152..dc7232b9e5 100644 --- a/docs/specs/agents/system-design/no-silent-model-fallback-02.md +++ b/docs/specs/agents/system-design/no-silent-model-fallback-02.md @@ -12,15 +12,14 @@ owners: ## Purpose and boundaries -This design records risks for executor-authoritative model fallback and unique -variation resolution. +This design records risks for executor-authoritative model fallback. ## Requirement mapping | Requirement | Design section | | --- | --- | | `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001` | [Migrated source detail](#migrated-source-detail) | -| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002` | [Risks & Open Questions](#risks--open-questions) | +| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002` | Superseded by exact-profile identity. | ## Migrated source detail @@ -55,14 +54,8 @@ variation resolution. - **Hover is supplementary**: every info icon is focusable, and coarse-pointer devices receive the same content in a drawer. The visible option helper copy remains the baseline explanation. -- **Provider-specific labels remain opaque**: `1m`, `270k`, and `fast` have no - Kandev-defined order. More than one variation therefore fails closed to the - existing provider-default path. -- **Host and executor decisions can differ**: a unique host variation is only - an advisory. The selected executor can advertise zero, one, or multiple - variations at launch. -- **Bracketed requests do not drift sideways**: a request such as `opus[1m]` - does not infer `opus[270k]`. The user must select another explicit model. +- **Provider-specific labels remain opaque**: Kandev does not infer a variation + from an exact requested model. The user must select another explicit model. - **Legacy automatic fallback remains unchanged**: when `auto_fallback` is enabled and the requested model is absent, Kandev does not apply an explicit fallback or infer a variation. It continues with the provider default.