diff --git a/docs/spec/configuration.md b/docs/spec/configuration.md index c273d4146..f2f4f099f 100644 --- a/docs/spec/configuration.md +++ b/docs/spec/configuration.md @@ -105,6 +105,13 @@ keys used by model references. Named model definitions own provider/model identity and metadata. Roles reference definitions, so changing Main or Fallback does not destroy overrides belonging to the previous model. +Capability discovery does not write `ContextWindow`, `InputModalities`, or `OutputModalities`. +The daemon resolves those dynamic values at startup when the operator leaves them absent. + +Older releases can contain discovery snapshots in these override fields. The stored shape +does not record each field's source. Netclaw preserves these values to protect explicit +operator overrides. Use `--clear-context-window` and `--clear-modalities` once to restore +runtime detection for an affected definition. ```json { @@ -142,7 +149,7 @@ so changing Main or Fallback does not destroy overrides belonging to the previou |-------|------|---------|-------------| | `Provider` | string | `"local-ollama"` | Key into the `Providers` dictionary. | | `ModelId` | string | `"qwen3:30b"` | Model identifier as used by the provider's API. | -| `ContextWindow` | int? | `null` | Effective runtime context window in tokens. When set, it clamps the detected provider value. If not set, Netclaw uses the provider-reported value when available, otherwise defaults to 32,768. | +| `ContextWindow` | int? | `null` | Operator override for the runtime context window. When set, it clamps the detected provider value. If not set, Netclaw uses the provider-reported value when available, otherwise defaults to 32,768. Model selection does not persist a discovered value here. | | `InputModalities` | string? | `null` | Manual override for input modalities. Comma-separated flags from `Text`, `Image`, `Audio`, `Video` — e.g. `"Text"` or `"Text, Image"`. When set, bypasses automated capability detection. | | `OutputModalities` | string? | `null` | Manual override for output modalities. Same form as `InputModalities`. | diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 1271d1dc3..c67a87120 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.39.0" + version: "2.40.0" --- # Netclaw Operations diff --git a/feeds/skills/.system/files/netclaw-operations/references/providers.md b/feeds/skills/.system/files/netclaw-operations/references/providers.md index 5cfd3a7a5..3db64f903 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/providers.md +++ b/feeds/skills/.system/files/netclaw-operations/references/providers.md @@ -88,19 +88,26 @@ openai` defaults to the ChatGPT OAuth device flow. Use `--auth api-key `netclaw model set ` creates or reuses a named model definition and assigns it to a role (`main`, `fallback`, `compaction`). Definitions own provider/model identity and metadata, while roles only reference definitions. -Switching away from a model and back therefore preserves its overrides. Two attributes can be overridden by the -operator and are **operator-owned**: the context window and the input/output -modalities. Provider discovery seeds a new definition but never changes an existing -definition, including adding a property the definition deliberately omits. +Switching away from a model and back therefore preserves its overrides. The operator +owns the context window and modality overrides. + +Provider discovery validates the model ID for selection. It does not persist the +discovered context window or modalities. The daemon detects these values at each +startup. Only explicit operator flags or manual configuration create capability +overrides. + +Older releases can contain discovery snapshots in the override fields. The config does +not record each field's source. Netclaw preserves these values to protect explicit +operator overrides. Run `netclaw model set` with `--clear-context-window` and +`--clear-modalities` once to restore runtime detection for an affected definition. - `--context-window ` clamps the session budget and takes precedence over provider-reported detection. Supplying it configures the model manually and skips the metadata probe. - `--input-modalities ` / `--output-modalities ` override detected modalities with a comma-separated list of named flags (`Text`, `Image`, - `Audio`, `Video`). These do **not** skip the probe — the model is still - validated and its context window discovered; the override just wins over the - discovered modalities. + `Audio`, `Video`). These do **not** skip the probe. The probe still validates + the model ID. The override wins over runtime detection. - `--clear-context-window` and `--clear-modalities` remove the respective override so runtime capability detection resolves it again (use these after a provider enlarges a model's window or fixes mis-reported modalities). diff --git a/src/Netclaw.Cli.Tests/Config/ModelEntryWriterTests.cs b/src/Netclaw.Cli.Tests/Config/ModelEntryWriterTests.cs index 28101310c..76805924d 100644 --- a/src/Netclaw.Cli.Tests/Config/ModelEntryWriterTests.cs +++ b/src/Netclaw.Cli.Tests/Config/ModelEntryWriterTests.cs @@ -6,6 +6,7 @@ using System.Text.Json; using Netclaw.Cli.Config; using Netclaw.Configuration; +using Netclaw.Daemon.Configuration; using Xunit; using ModalityOverride = Netclaw.Cli.Config.ValueOverride; using ContextWindowOverride = Netclaw.Cli.Config.ValueOverride; @@ -14,10 +15,9 @@ namespace Netclaw.Cli.Tests.Config; /// /// Guards the single persist path shared by `model set`, the init wizard, and the TUI -/// model manager. The rule under test: modalities are written only when the discovery -/// source genuinely reported them, so an unknown is never frozen into config as a -/// permanent "Text" override (#1290), and operator-owned overrides survive re-selection -/// (#1127 / #1610). +/// model manager. The writer persists capability values only after explicit operator input. +/// Runtime detection owns provider capability data (#1756). +/// Operator overrides survive model re-selection (#1127 and #1610). /// public class ModelEntryWriterTests { @@ -74,7 +74,7 @@ public void WriteRole_SameModelWithoutModalities_PreservesHandSetModalities() // Re-set the same model with only a context-window change; no modality intent supplied. ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Manual, - ContextWindowOverride.Set(131072), ModalityOverride.Unset, ModalityOverride.Unset, discovered: null); + ContextWindowOverride.Set(131072), ModalityOverride.Unset, ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); Assert.Equal("Text, Image", entry["InputModalities"]); // preserved (#1127) @@ -82,7 +82,7 @@ public void WriteRole_SameModelWithoutModalities_PreservesHandSetModalities() } [Fact] - public void WriteRole_SameModel_PreservesExistingClampOverDiscoveredWindow() + public void WriteRole_SameModel_PreservesExistingClampWithoutExplicitChange() { // Operator clamped ContextWindow below what the provider reports. var models = Models( @@ -90,39 +90,66 @@ public void WriteRole_SameModel_PreservesExistingClampOverDiscoveredWindow() { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "ContextWindow": 32000 } } """); - // Re-select the same model (picker path): no explicit --context-window, but the probe - // reports the model's full window. The operator's clamp must win (#1610): ContextWindow - // is documented to take precedence over provider-reported detection. + // Re-select the same model through a picker that has no override input. ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, - Discovered(contextWindow: 128000)); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); Assert.Equal(32000, entry["ContextWindow"]); } [Fact] - public void WriteRole_FirstTimeSet_UsesDiscoveredWindow() + public void WriteRole_FirstTimeSet_OmitsCapabilityOverrides() { - // No existing entry for the role: discovery is the fallback that seeds the value. var models = new Dictionary(); ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, - Discovered(contextWindow: 128000)); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); - Assert.Equal(128000, entry["ContextWindow"]); + Assert.False(entry.ContainsKey("ContextWindow")); + Assert.False(entry.ContainsKey("InputModalities")); + Assert.False(entry.ContainsKey("OutputModalities")); } [Fact] - public void WriteRole_ClearContextWindow_DropsStoredClampAndDiscoveredWindow() + public void WriteRole_ProviderCapabilityChange_ReachesRuntimeResolution() { - // Operator clamped the window; now --clear-context-window removes the clamp so runtime - // detection resolves it. Clear wins over BOTH the stored value and a probe that reports - // a window (#1610) — mirroring --clear-modalities. + var models = new Dictionary(); + var selectedDuringProbe = new DiscoveredModel + { + ModelId = new ModelId("deepseek-v4-flash-dspark"), + ContextWindowTokens = 327680, + InputModalities = ModelModality.Text, + OutputModalities = ModelModality.Text, + }; + + // The writer receives the selected identity, but it does not receive dynamic capabilities. + ModelEntryWriter.WriteRole( + models, "Main", "spark", selectedDuringProbe.ModelId.Value, ModelDiscoverySource.Live, + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); + + var main = ConfigFileHelper.DeserializeSection(ActiveEntry(models, "Main"))!; + var detectedAtStartup = new ResolvedModelCapabilities( + main.ModelId, + ModelModality.Text | ModelModality.Image, + ModelModality.Text, + 100000); + + var resolved = ModelCapabilityResolution.ResolveModelCapabilities( + new ModelSelection { Main = main }, detectedAtStartup); + + Assert.Equal(100000, resolved.ContextWindowTokens); + Assert.Equal(ModelModality.Text | ModelModality.Image, resolved.InputModalities); + Assert.Equal(ModelModality.Text, resolved.OutputModalities); + } + + [Fact] + public void WriteRole_ClearContextWindow_DropsStoredClamp() + { + // The operator removes the clamp. Runtime detection resolves the window. var models = Models( """ { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "ContextWindow": 32000 } } @@ -130,8 +157,7 @@ public void WriteRole_ClearContextWindow_DropsStoredClampAndDiscoveredWindow() ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, - ContextWindowOverride.Clear, ModalityOverride.Unset, ModalityOverride.Unset, - Discovered(contextWindow: 128000)); + ContextWindowOverride.Clear, ModalityOverride.Unset, ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); Assert.False(entry.ContainsKey("ContextWindow")); // clamp removed → runtime detection @@ -147,27 +173,22 @@ public void WriteRole_SameDefinitionWithoutWindow_DiscoveryDoesNotResurrect() ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, - Discovered(contextWindow: 128000)); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); Assert.False(ActiveEntry(models, "Main").ContainsKey("ContextWindow")); } [Fact] - public void WriteRole_SameModelFreshProbe_DoesNotOverrideExistingModalities() + public void WriteRole_SameModel_PreservesExistingModalities() { - // Operator override on disk; a fresh probe reports a coarser Text-only capability. var models = Models( """ { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "InputModalities": "Text, Image" } } """); - // The stored override wins — discovery never silently overwrites it (#1610 / #5). This is - // the same rule as ContextWindow: the field is documented to bypass automated detection. ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, - Discovered(input: ModelModality.Text, output: ModelModality.Text)); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); Assert.Equal("Text, Image", entry["InputModalities"]); @@ -187,8 +208,7 @@ public void WriteRole_SameModelEntryClearedModality_DiscoveryDoesNotResurrect() ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, - Discovered(input: ModelModality.Text | ModelModality.Image)); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); Assert.False(entry.ContainsKey("InputModalities")); // stays cleared, not resurrected @@ -207,7 +227,7 @@ public void WriteRole_ExplicitModalityOverride_ReplacesExisting() ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Manual, ContextWindowOverride.Unset, - ModalityOverride.Set(ModelModality.Text), ModalityOverride.Unset, discovered: null); + ModalityOverride.Set(ModelModality.Text), ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); Assert.Equal("Text", entry["InputModalities"]); @@ -221,12 +241,10 @@ public void WriteRole_ClearModalities_RemovesOverrideEvenWhenProbeReportsOne() { "Main": { "Provider": "spark", "ModelId": "qwen-vl", "InputModalities": "Text, Image", "OutputModalities": "Text" } } """); - // --clear-modalities removes both overrides so runtime detection resolves them; clear - // wins over BOTH the stored value and a probe that still reports modalities (#1610 / #4). + // The command removes both overrides. Runtime detection resolves the modalities. ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Manual, - ContextWindowOverride.Unset, ModalityOverride.Clear, ModalityOverride.Clear, - Discovered(input: ModelModality.Text | ModelModality.Image)); + ContextWindowOverride.Unset, ModalityOverride.Clear, ModalityOverride.Clear); var entry = ActiveEntry(models, "Main"); Assert.False(entry.ContainsKey("InputModalities")); @@ -246,7 +264,7 @@ public void WriteRole_ExistingEntryOmitsModelId_DoesNotFalseMatchDefaultModel() ModelEntryWriter.WriteRole( models, "Main", "local-ollama", "qwen3:30b", ModelDiscoverySource.Manual, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, discovered: null); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); Assert.Equal("qwen3:30b", entry["ModelId"]); @@ -269,7 +287,7 @@ public void WriteRole_SameModel_PreservesProvenanceUnlessFreshlyDiscovered( ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", incoming, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, discovered: null); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); Assert.Equal(expected.ToString(), entry["Provenance"]); @@ -288,8 +306,7 @@ public void WriteRole_CorruptExistingEntry_OverwritesInsteadOfThrowing() var ex = Record.Exception(() => ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, - Discovered(contextWindow: 128000))); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset)); Assert.Null(ex); var entry = ActiveEntry(models, "Main"); @@ -311,8 +328,7 @@ public void WriteRole_CorruptModalityButValidWindow_PreservesWindow() ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, - Discovered(contextWindow: 128000)); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); Assert.Equal(32768, entry["ContextWindow"]); // operator clamp preserved, not clobbered @@ -331,12 +347,11 @@ public void WriteRole_CorruptEntryForDifferentModel_DoesNotLeakWindow() ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Live, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, - Discovered(contextWindow: 128000)); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); var entry = ActiveEntry(models, "Main"); Assert.Equal("qwen-vl", entry["ModelId"]); - Assert.Equal(128000, entry["ContextWindow"]); // discovered window, not the other model's 32768 + Assert.False(entry.ContainsKey("ContextWindow")); } [Fact] @@ -350,13 +365,13 @@ public void WriteRole_SwitchAwayAndBack_PreservesPreviousModelModalities() // Switching roles changes only the role reference. The old definition remains intact. ModelEntryWriter.WriteRole( models, "Main", "spark", "other-model", ModelDiscoverySource.Manual, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, discovered: null); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); Assert.Equal("other-model", ActiveEntry(models, "Main")["ModelId"]); ModelEntryWriter.WriteRole( models, "Main", "spark", "qwen-vl", ModelDiscoverySource.Manual, - ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset, discovered: null); + ContextWindowOverride.Unset, ModalityOverride.Unset, ModalityOverride.Unset); Assert.Equal("Text, Image", ActiveEntry(models, "Main")["InputModalities"]); } @@ -373,13 +388,4 @@ private static Dictionary ActiveEntry( return (Dictionary)definitions[definitionName]; } - private static DiscoveredModel Discovered( - int? contextWindow = null, ModelModality? input = null, ModelModality? output = null) - => new() - { - ModelId = new ModelId("qwen-vl"), - ContextWindowTokens = contextWindow, - InputModalities = input, - OutputModalities = output, - }; } diff --git a/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs b/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs index 7917fb3fc..4709ba66c 100644 --- a/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs @@ -97,7 +97,7 @@ public async Task Set_MainModel_WritesConfig() } [Fact] - public async Task Set_OpenAiOAuthModel_StoresLiveDiscoveredMetadata() + public async Task Set_OpenAiOAuthModel_DoesNotPersistDiscoveredCapabilities() { WriteConfig(new Dictionary { @@ -130,9 +130,9 @@ public async Task Set_OpenAiOAuthModel_StoresLiveDiscoveredMetadata() var config = ReadConfigFile(_paths.NetclawConfigPath); var main = ReadActiveModel(config, "Main"); Assert.Equal("Live", main.GetProperty("Provenance").GetString()); - Assert.Equal(512000, main.GetProperty("ContextWindow").GetInt32()); - Assert.Equal("Text, Image", main.GetProperty("InputModalities").GetString()); - Assert.Equal("Text", main.GetProperty("OutputModalities").GetString()); + Assert.False(main.TryGetProperty("ContextWindow", out _)); + Assert.False(main.TryGetProperty("InputModalities", out _)); + Assert.False(main.TryGetProperty("OutputModalities", out _)); } [Fact] @@ -530,8 +530,7 @@ public async Task Set_OAuthModelWithModalityOverride_StillProbesAndValidates() } ]); - // A modality override no longer short-circuits the probe: the probe must still run to - // validate the model and discover the context window, while the operator's modality wins. + // The probe still validates the model. The operator's modality remains authoritative. var exitCode = await ModelCommand.RunAsync( ["model", "set", "main", "openai-codex", "gpt-new-codex", "--input-modalities", "Text"], _paths, _fakeProbe, output: _output); @@ -540,9 +539,9 @@ public async Task Set_OAuthModelWithModalityOverride_StillProbesAndValidates() Assert.Equal(1, _fakeProbe.ProbeCallCount); // probe ran despite the modality flag using var config = ReadConfigFile(_paths.NetclawConfigPath); var main = ReadActiveModel(config, "Main"); - Assert.Equal("Live", main.GetProperty("Provenance").GetString()); // resolved via probe - Assert.Equal(512000, main.GetProperty("ContextWindow").GetInt32()); // discovered window captured - Assert.Equal("Text", main.GetProperty("InputModalities").GetString());// operator override wins + Assert.Equal("Live", main.GetProperty("Provenance").GetString()); + Assert.False(main.TryGetProperty("ContextWindow", out _)); + Assert.Equal("Text", main.GetProperty("InputModalities").GetString()); } [Fact] diff --git a/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs index 460d12dc0..818e2646a 100644 --- a/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs @@ -171,7 +171,7 @@ public async Task ConfirmAssignment_WritesCorrectConfig() } [Fact] - public async Task ConfirmAssignment_DiscoveredModelWithMetadata_WritesMetadata() + public async Task ConfirmAssignment_DiscoveredModelWithMetadata_OmitsCapabilityOverrides() { WriteConfig(new Dictionary { @@ -207,9 +207,9 @@ public async Task ConfirmAssignment_DiscoveredModelWithMetadata_WritesMetadata() var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); var main = ReadActiveModel(config, "Main"); Assert.Equal("Live", main.GetProperty("Provenance").GetString()); - Assert.Equal(512000, main.GetProperty("ContextWindow").GetInt32()); - Assert.Equal("Text, Image", main.GetProperty("InputModalities").GetString()); - Assert.Equal("Text", main.GetProperty("OutputModalities").GetString()); + Assert.False(main.TryGetProperty("ContextWindow", out _)); + Assert.False(main.TryGetProperty("InputModalities", out _)); + Assert.False(main.TryGetProperty("OutputModalities", out _)); } [Fact] diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/ProviderStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/ProviderStepViewModelTests.cs index e2dd5a440..10e5d090b 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/ProviderStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/ProviderStepViewModelTests.cs @@ -240,7 +240,7 @@ public void ContributeConfig_SetsProviderAndModel() } [Fact] - public void ContributeConfig_SelectedDiscoveredModel_CarriesModelMetadata() + public void ContributeConfig_SelectedDiscoveredModel_OmitsCapabilityOverrides() { using var step = new ProviderStepViewModel(_registry, _fakeProbe); step.SelectedProviderType = "OpenAI"; @@ -258,10 +258,16 @@ public void ContributeConfig_SelectedDiscoveredModel_CarriesModelMetadata() step.ContributeConfig(builder); Assert.NotNull(builder.Model); - Assert.Equal(512000, builder.Model!.ContextWindow); - Assert.Equal(ModelDiscoverySource.Live, builder.Model.Provenance); - Assert.Equal(ModelModality.Text | ModelModality.Image, builder.Model.InputModalities); - Assert.Equal(ModelModality.Text, builder.Model.OutputModalities); + Assert.Equal(ModelDiscoverySource.Live, builder.Model!.Provenance); + + var config = builder.BuildConfigDictionary(); + var models = (Dictionary)config["Models"]; + var roles = (Dictionary)models["Roles"]; + var definitions = (Dictionary)models["Definitions"]; + var main = (Dictionary)definitions[(string)roles["Main"]]; + Assert.False(main.ContainsKey("ContextWindow")); + Assert.False(main.ContainsKey("InputModalities")); + Assert.False(main.ContainsKey("OutputModalities")); } [Fact] @@ -366,7 +372,7 @@ public void GitHubCopilotEnterpriseInputs_RejectInvalidValuesBeforeVendorOptions } [Fact] - public void ContributeConfig_DiscoveredModelWithoutModalities_PersistsNone() + public void ContributeConfig_DiscoveredModelWithoutModalities_OmitsCapabilityOverrides() { // An openai-compatible /v1/models listing reports no modalities, so the // discovered model leaves them unset. The wizard must NOT bake a guessed Text @@ -386,9 +392,16 @@ public void ContributeConfig_DiscoveredModelWithoutModalities_PersistsNone() step.ContributeConfig(builder); Assert.NotNull(builder.Model); - Assert.Equal(32768, builder.Model!.ContextWindow); - Assert.Null(builder.Model.InputModalities); - Assert.Null(builder.Model.OutputModalities); + Assert.Equal(ModelDiscoverySource.Live, builder.Model!.Provenance); + + var config = builder.BuildConfigDictionary(); + var models = (Dictionary)config["Models"]; + var roles = (Dictionary)models["Roles"]; + var definitions = (Dictionary)models["Definitions"]; + var main = (Dictionary)definitions[(string)roles["Main"]]; + Assert.False(main.ContainsKey("ContextWindow")); + Assert.False(main.ContainsKey("InputModalities")); + Assert.False(main.ContainsKey("OutputModalities")); } [Fact] diff --git a/src/Netclaw.Cli/Config/ModelEntryWriter.cs b/src/Netclaw.Cli/Config/ModelEntryWriter.cs index 4df3ef243..465feddc3 100644 --- a/src/Netclaw.Cli/Config/ModelEntryWriter.cs +++ b/src/Netclaw.Cli/Config/ModelEntryWriter.cs @@ -62,23 +62,13 @@ internal static bool ClearRole(Dictionary modelsSection, string } /// - /// Write a role's model entry into non-destructively. - /// Two on-disk attributes are treated as operator-owned overrides that provider discovery - /// must never silently clobber on a same-(provider, modelId) re-set: - /// - /// - /// ContextWindow and the modalities are documented to "take precedence over - /// provider-reported capability detection". So the precedence for each is: - /// explicit operator input (this call) > existing stored value > probe. A fresh probe - /// tops up a first-time set or a model switch, but never overwrites a value already on disk - /// — that overwrite was the #1127 loss (a re-set wiped a hand-set modality) and its - /// context-window twin (#1610). - /// - /// - /// Because the stored value now wins, the operator needs a way to *change* it: an explicit + /// Write a role's model entry into without data loss. + /// ContextWindow and the modalities are operator-owned overrides. + /// Provider discovery must not create or change these overrides. + /// An explicit /// replaces it and - /// removes it (falling back to runtime detection). Switching a role to a different - /// model does not carry the old model's attributes over — they belonged to that model. + /// removes it. Runtime capability detection resolves an absent override. + /// A model switch does not copy overrides from the old model. /// /// /// Operator intent for the context window (set / clear / unset). --context-window sets @@ -87,10 +77,6 @@ internal static bool ClearRole(Dictionary modelsSection, string /// /// Operator intent for input modalities (set / clear / unset). /// Operator intent for output modalities (set / clear / unset). - /// - /// The probe result, if a probe ran. Its context window and modalities seed a first-time set - /// or a model switch only; an existing stored value wins over them. - /// internal static void WriteRole( Dictionary modelsSection, string roleKey, @@ -99,8 +85,7 @@ internal static void WriteRole( ModelDiscoverySource? provenance, ValueOverride contextWindow, ValueOverride inputModalities, - ValueOverride outputModalities, - DiscoveredModel? discovered) + ValueOverride outputModalities) { var (definitions, roles) = EnsureNamedShape(modelsSection, roleKey); var definitionName = FindDefinition(definitions, provider, modelId) @@ -114,15 +99,11 @@ internal static void WriteRole( if (existing?.Provenance is { } priorProvenance && provenance != ModelDiscoverySource.Live) provenance = priorProvenance; - // Precedence for every operator-owned attribute: explicit input > existing stored value - // > probe. Collapsing the explicit and discovered values in the caller defeated - // preservation, because the probe/picker paths always pass a discovered value, so the - // stored value was overwritten on every re-selection. - var sameModelEntry = existing is not null; - var resolvedWindow = ResolveContextWindow( - contextWindow, sameModelEntry, existing?.ContextWindow, discovered?.ContextWindowTokens); - var resolvedInput = ResolveModality(inputModalities, sameModelEntry, existing?.InputModalities, discovered?.InputModalities); - var resolvedOutput = ResolveModality(outputModalities, sameModelEntry, existing?.OutputModalities, discovered?.OutputModalities); + // Explicit operator input wins. Otherwise, the existing operator override remains. + // A new definition stays empty so runtime detection owns dynamic capabilities (#1756). + var resolvedWindow = contextWindow.Supplied ? contextWindow.Value : existing?.ContextWindow; + var resolvedInput = inputModalities.Supplied ? inputModalities.Value : existing?.InputModalities; + var resolvedOutput = outputModalities.Supplied ? outputModalities.Value : existing?.OutputModalities; definitions[definitionName] = BuildModelEntry( provider, modelId, provenance, resolvedWindow, resolvedInput, resolvedOutput); @@ -284,35 +265,6 @@ private static bool Equivalent(ModelReference left, ModelReference right) && left.InputModalities == right.InputModalities && left.OutputModalities == right.OutputModalities; - /// - /// Resolves the modality actually written. An explicit operator set or clear wins outright - /// (the operator is the authority on a manual override). Otherwise, when the same model already - /// has an entry on disk, that entry is honored verbatim — including a deliberately-cleared - /// (absent) modality, so a later probe cannot silently resurrect an override the operator - /// removed with --clear-modalities (#1610). Provider discovery only seeds a genuine gap: - /// a first-time set or a switch to a different model, where no entry exists yet. - /// - private static ModelModality? ResolveModality( - ValueOverride @override, bool sameModelEntryExists, - ModelModality? existing, ModelModality? discovered) - => @override.Supplied - ? @override.Value // Set(value) → value; Clear → null (key omitted downstream) - : sameModelEntryExists ? existing // same model on disk: honor it, incl. a cleared (null) value - : discovered; // first set / model switch: seed from discovery - - /// - /// Resolves the context window actually written. Mirrors for the - /// explicit cases: --context-window (Set) or --clear-context-window (Clear) wins, - /// otherwise an existing definition is honored verbatim, including absence. Discovery seeds - /// only a new definition. This keeps manual JSON edits and explicit clears stable without a - /// hidden tombstone representation. - /// - private static int? ResolveContextWindow( - ValueOverride @override, bool sameModelEntryExists, int? existing, int? discovered) - => @override.Supplied - ? @override.Value // Set(n) → n; Clear → null (drop the clamp → runtime detects) - : sameModelEntryExists ? existing : discovered; - /// /// The role's current entry, but only when it already references the same /// (provider, modelId); null when the role is unset or points at a different @@ -461,16 +413,9 @@ private static bool RawContainsProperty(object raw, string propertyName) } /// - /// Builds the dictionary written under Models[role]. + /// Builds a model definition from operator overrides or legacy persisted values. + /// Null capability values stay absent so runtime detection can resolve them. /// - /// - /// Modalities are written ONLY when the discovery source genuinely reported them - /// (non-null). A null modality means "the provider did not say" — it is - /// deliberately omitted so the daemon's capability detection resolves it at - /// runtime. Writing a guessed here would bake a - /// permanent override into config that beats real detection on every boot, which - /// is exactly what silently demoted multimodal self-hosted models to text-only. - /// internal static Dictionary BuildModelEntry( string provider, string? modelId, @@ -505,14 +450,14 @@ internal static Dictionary BuildModelEntry( /// a modality set () or the context window (). A plain /// T? cannot express it, because two of the three states both resolve to null yet behave /// oppositely: "not supplied" must preserve any existing override, while "clear" must win over it. -/// The tri-state is: (leave it to the stored value / discovery), +/// The tri-state is: (leave the stored value unchanged), /// (replace with an explicit value), and (remove the override /// so runtime detection resolves it). /// internal readonly record struct ValueOverride(bool Supplied, T? Value) where T : struct { - /// Operator said nothing — preserve the stored value, else fall back to discovery. + /// Operator said nothing. Preserve the stored value. internal static ValueOverride Unset => default; /// Operator asked to remove the override so runtime capability detection resolves it. diff --git a/src/Netclaw.Cli/Model/ModelCommand.cs b/src/Netclaw.Cli/Model/ModelCommand.cs index c811789c2..227d4daf0 100644 --- a/src/Netclaw.Cli/Model/ModelCommand.cs +++ b/src/Netclaw.Cli/Model/ModelCommand.cs @@ -235,17 +235,10 @@ private static async Task RunSetAsync( } } - // Only an explicit --context-window short-circuits the probe: it supplies the one datum the - // probe would discover, so it is the documented "configure this model manually" escape - // hatch. A modality override must NOT skip the probe — the probe also validates the model - // exists and discovers the context window, and WriteRole already lets an explicit modality - // override win over any discovered value, so it is safe to keep probing. --clear-context-window - // likewise keeps the probe: "re-detect" is exactly what the probe does. - var manualMetadataSupplied = contextWindow.HasValue; - - DiscoveredModel? discoveredModel = null; + // An explicit context window permits a manual model ID. + // All other OAuth selections require validation against the live model list. var provenance = ModelDiscoverySource.Manual; - if (!manualMetadataSupplied && ShouldProbeForMetadata(providerEntry)) + if (!contextWindow.HasValue && ShouldProbeForMetadata(providerEntry)) { probe ??= ProviderCommand.CreateDefaultRegistry(); ProviderProbeResult probeResult; @@ -258,7 +251,7 @@ private static async Task RunSetAsync( return 1; } - discoveredModel = probeResult.Models.FirstOrDefault(m => + var discoveredModel = probeResult.Models.FirstOrDefault(m => string.Equals(m.ModelId.Value, modelId, StringComparison.OrdinalIgnoreCase)); if (discoveredModel is null) { @@ -274,9 +267,8 @@ private static async Task RunSetAsync( var (config, _) = ConfigFileHelper.LoadConfigFiles(paths); var modelsSection = ConfigFileHelper.GetOrCreateSection(config, "Models"); - // Definitions own model metadata, so role switches never destroy another model's - // operator-owned overrides. Discovery seeds only a new definition; explicit input edits - // an existing definition and absence remains runtime detection (#1127, #1610). + // Definitions own model metadata. Role switches preserve another model's overrides. + // Only explicit operator input persists capability overrides (#1756). ModelEntryWriter.WriteRole( modelsSection, roleKey, @@ -285,8 +277,7 @@ private static async Task RunSetAsync( provenance, contextWindowOverride, inputOverride, - outputOverride, - discoveredModel); + outputOverride); ConfigFileHelper.WriteConfigFile(paths.NetclawConfigPath, config); writer.WriteLine($"Set {role} model to {providerName}/{modelId}"); diff --git a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs index efdbf3447..fa26f37da 100644 --- a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs @@ -194,10 +194,8 @@ public void ConfirmAssignment() var (config, _) = ConfigFileHelper.LoadConfigFiles(_paths); var modelsSection = ConfigFileHelper.GetOrCreateSection(config, "Models"); - // Non-destructive: re-assigning the same model preserves an existing context-window - // clamp and modality overrides, none of which the picker can supply (#1127, #1610). The - // picker has no manual-override inputs, so it passes no explicit context window and Unset - // modality intent — the probe result seeds a first-time set only; existing values win. + // The picker has no inputs for capability overrides. + // It preserves existing overrides and leaves new definitions empty (#1756). ModelEntryWriter.WriteRole( modelsSection, roleKey, @@ -206,8 +204,7 @@ public void ConfirmAssignment() provenance, ValueOverride.Unset, ValueOverride.Unset, - ValueOverride.Unset, - discoveredModel); + ValueOverride.Unset); ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); Refresh(); diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs index 4a684e89f..8a95ed989 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs @@ -462,10 +462,7 @@ public void ContributeConfig(WizardConfigBuilder builder) { Provider = providerName, ModelId = SelectedModelId, - ContextWindow = selectedModel?.ContextWindowTokens, Provenance = selectedModel is null ? ModelDiscoverySource.Manual : ModelDiscoverySource.Live, - InputModalities = selectedModel?.InputModalities, - OutputModalities = selectedModel?.OutputModalities, }; } diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index 6ebf3811f..5d56caf07 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -141,16 +141,9 @@ internal Dictionary BuildConfigDictionary() Model.Provider, Model.ModelId, Model.Provenance, - Model.ContextWindow is { } contextWindow - ? ValueOverride.Set(contextWindow) - : ValueOverride.Unset, - Model.InputModalities is { } input - ? ValueOverride.Set(input) - : ValueOverride.Unset, - Model.OutputModalities is { } output - ? ValueOverride.Set(output) - : ValueOverride.Unset, - discovered: null); + ValueOverride.Unset, + ValueOverride.Unset, + ValueOverride.Unset); } // Slack section @@ -580,10 +573,7 @@ public sealed class ModelConfigSection { public required string Provider { get; init; } public string? ModelId { get; init; } - public int? ContextWindow { get; init; } public ModelDiscoverySource? Provenance { get; init; } - public ModelModality? InputModalities { get; init; } - public ModelModality? OutputModalities { get; init; } } public sealed class SlackConfigSection diff --git a/tests/smoke/assertions/init-wizard.sh b/tests/smoke/assertions/init-wizard.sh index 2f7185680..94b0fd793 100755 --- a/tests/smoke/assertions/init-wizard.sh +++ b/tests/smoke/assertions/init-wizard.sh @@ -63,6 +63,9 @@ assert_field '.Providers.ollama.Endpoint' 'http://localhost:11434' "$config_ assert_field '.Models.Roles.Main' 'ollama-qwen2-0-5b' "$config_json" || : assert_field '.Models.Definitions[.Models.Roles.Main].Provider' 'ollama' "$config_json" || : assert_field '.Models.Definitions[.Models.Roles.Main].ModelId' 'qwen2:0.5b' "$config_json" || : +assert_field '(.Models.Definitions[.Models.Roles.Main] | has("ContextWindow"))' 'false' "$config_json" || : +assert_field '(.Models.Definitions[.Models.Roles.Main] | has("InputModalities"))' 'false' "$config_json" || : +assert_field '(.Models.Definitions[.Models.Roles.Main] | has("OutputModalities"))' 'false' "$config_json" || : assert_field '.Security.DeploymentPosture' 'Personal' "$config_json" || : echo "init-wizard: checking identity/SOUL.md for typed user name..."