A full model id like claude-sonnet-4-6 in list_config.model never matched any ModelPresets row (only bare aliases did), so every run under it landed on the hardcoded 30-turn/DefaultLevel fallback instead of the sonnet preset row - this is what killed two autonomous tasks at the 30-turn limit. ModelRegistry.TryNormalizeAlias (non-throwing: exact match, then substring match against a full model id) lets ModelPresets.For resolve a full model id to its alias's preset row. For a model that still doesn't resolve, the hardcoded 30 is replaced by a caller-supplied fallbackMaxTurns, and TaskRunner now passes AppSettings.DefaultMaxTurns there - so that setting has a real effect instead of being dead, matching the direction already noted in docs/open.md.
98 lines
4.5 KiB
C#
98 lines
4.5 KiB
C#
using System.Text.Json;
|
|
|
|
namespace ClaudeDo.Data.Models;
|
|
|
|
/// <summary>Per-model run defaults: which reasoning effort and turn budget a session gets when
|
|
/// it runs under <paramref name="Model"/> and nothing more specific overrides it.</summary>
|
|
public sealed record ModelPreset(string Model, string Effort, int MaxTurns);
|
|
|
|
/// <summary>
|
|
/// Serialization + lookup for <see cref="AppSettingsEntity.ModelPresets"/> (a JSON array on the
|
|
/// singleton settings row). One row per <see cref="ModelRegistry.Aliases"/> entry; the row supplies
|
|
/// the *global* effort and max-turns defaults, which list- and task-level overrides still beat.
|
|
/// </summary>
|
|
public static class ModelPresets
|
|
{
|
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
|
|
|
/// <summary>Shipping defaults — one row per known model alias.</summary>
|
|
public static IReadOnlyList<ModelPreset> Defaults { get; } = new[]
|
|
{
|
|
new ModelPreset("haiku", "medium", 20),
|
|
new ModelPreset("sonnet", "high", 30),
|
|
new ModelPreset("opus", "high", 40),
|
|
new ModelPreset("fable", "high", 25),
|
|
};
|
|
|
|
public static string SerializeDefaults() => Serialize(Defaults);
|
|
|
|
public static string Serialize(IEnumerable<ModelPreset> presets)
|
|
=> JsonSerializer.Serialize(Normalize(presets), Json);
|
|
|
|
/// <summary>Parses the stored JSON, falling back to <see cref="Defaults"/> for anything absent
|
|
/// or malformed — a bad settings row must never stop a run from starting.</summary>
|
|
public static IReadOnlyList<ModelPreset> Parse(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return Defaults;
|
|
try
|
|
{
|
|
var parsed = JsonSerializer.Deserialize<List<ModelPreset>>(json, Json);
|
|
return parsed is { Count: > 0 } ? Normalize(parsed) : Defaults;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return Defaults;
|
|
}
|
|
}
|
|
|
|
/// <summary>The preset for <paramref name="model"/>, or the default row for it. Never null so
|
|
/// callers don't have to special-case an unconfigured or unknown model. <paramref name="model"/>
|
|
/// is resolved through <see cref="ModelRegistry.TryNormalizeAlias"/> first, so a full CLI model
|
|
/// id (e.g. "claude-sonnet-4-6") still hits its alias's preset row instead of falling through.
|
|
/// If <paramref name="model"/> normalizes to nothing recognized, <paramref name="fallbackMaxTurns"/>
|
|
/// (typically <c>AppSettings.DefaultMaxTurns</c>) is used instead of a hardcoded number, so a
|
|
/// truly unknown model is no harsher than the configured global default.</summary>
|
|
public static ModelPreset For(IReadOnlyList<ModelPreset> presets, string? model, int fallbackMaxTurns = 30)
|
|
{
|
|
var alias = ModelRegistry.TryNormalizeAlias(model) ?? (model ?? ModelRegistry.DefaultAlias).Trim();
|
|
foreach (var p in presets)
|
|
if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase))
|
|
return p;
|
|
foreach (var p in Defaults)
|
|
if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase))
|
|
return p;
|
|
return new ModelPreset(alias, EffortRegistry.DefaultLevel, fallbackMaxTurns);
|
|
}
|
|
|
|
public static ModelPreset For(string? json, string? model, int fallbackMaxTurns = 30)
|
|
=> For(Parse(json), model, fallbackMaxTurns);
|
|
|
|
// One row per known alias, canonical order, clamped values — so an edited or partial list
|
|
// still round-trips into something every consumer can rely on.
|
|
private static IReadOnlyList<ModelPreset> Normalize(IEnumerable<ModelPreset> presets)
|
|
{
|
|
var byModel = new Dictionary<string, ModelPreset>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var p in presets)
|
|
{
|
|
if (p is null || string.IsNullOrWhiteSpace(p.Model)) continue;
|
|
byModel[p.Model.Trim()] = p;
|
|
}
|
|
|
|
var result = new List<ModelPreset>();
|
|
foreach (var fallback in Defaults)
|
|
{
|
|
var source = byModel.TryGetValue(fallback.Model, out var found) ? found : fallback;
|
|
var effort = TryNormalizeEffort(source.Effort) ?? fallback.Effort;
|
|
var turns = source.MaxTurns is >= 1 and <= 200 ? source.MaxTurns : fallback.MaxTurns;
|
|
result.Add(new ModelPreset(fallback.Model, effort, turns));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static string? TryNormalizeEffort(string? effort)
|
|
{
|
|
try { return EffortRegistry.NormalizeLevel(effort); }
|
|
catch (ArgumentException) { return null; }
|
|
}
|
|
}
|