feat(settings): per-model effort and turn presets
ClaudeDo never passed --effort, so every session inherited whatever effortLevel the user's Claude Code config happened to carry. Settings -> General now holds one row per model alias (haiku medium/20, sonnet high/30, opus high/40, fable high/25) supplying the global effort and turn defaults; list- and task-level max-turns overrides still win, and the agent editor's inherited badge follows the model. --effort is applied to autonomous runs and to every ConPTY spec (task session, planning start/resume, ad-hoc, list handler). The model itself is deliberately not forced on interactive sessions. The single global 'Max turns' field is replaced by the table, and 'fable' joins ModelRegistry.Aliases. The migration also adds the is_manual columns used by the next commit.
This commit is contained in:
@@ -30,4 +30,8 @@ public sealed class AppSettingsEntity
|
||||
|
||||
// JSON array of session skill names applied by default to new tasks.
|
||||
public string? SessionSkills { get; set; }
|
||||
|
||||
// JSON array of ModelPreset rows (model → effort + max turns). Supplies the global effort and
|
||||
// turn defaults per model; list-/task-level max-turns overrides still win. Null = ship defaults.
|
||||
public string? ModelPresets { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The reasoning-effort levels the claude CLI accepts for <c>--effort</c>.
|
||||
/// </summary>
|
||||
public static class EffortRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Levels = new[] { "low", "medium", "high", "xhigh", "max" };
|
||||
|
||||
public const string DefaultLevel = "high";
|
||||
|
||||
/// <summary>
|
||||
/// Validate an effort level from external input. Null/blank → null (don't pass --effort).
|
||||
/// Returns the canonical lowercase level; throws on an unknown value.
|
||||
/// </summary>
|
||||
public static string? NormalizeLevel(string? effort)
|
||||
{
|
||||
var e = effort?.Trim();
|
||||
if (string.IsNullOrEmpty(e)) return null;
|
||||
foreach (var level in Levels)
|
||||
if (string.Equals(level, e, StringComparison.OrdinalIgnoreCase))
|
||||
return level;
|
||||
throw new ArgumentException($"Unknown effort '{effort}'. Allowed: {string.Join(", ", Levels)}.");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ public sealed class ListEntity
|
||||
public string? WorkingDir { get; set; }
|
||||
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
|
||||
public int SortOrder { get; set; }
|
||||
// A manual list holds reminders rather than Claude work: new tasks created here start out
|
||||
// manual (see TaskEntity.IsManual).
|
||||
public bool IsManual { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ListConfigEntity? Config { get; set; }
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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.</summary>
|
||||
public static ModelPreset For(IReadOnlyList<ModelPreset> presets, string? model)
|
||||
{
|
||||
var alias = (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, 30);
|
||||
}
|
||||
|
||||
public static ModelPreset For(string? json, string? model) => For(Parse(json), model);
|
||||
|
||||
// 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; }
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ namespace ClaudeDo.Data.Models;
|
||||
|
||||
public static class ModelRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" };
|
||||
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku", "fable" };
|
||||
|
||||
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
|
||||
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
|
||||
|
||||
@@ -43,6 +43,10 @@ public sealed class TaskEntity
|
||||
public int? MaxTurns { get; set; }
|
||||
public bool IsStarred { get; set; }
|
||||
public bool IsMyDay { get; set; }
|
||||
// Manual = a reminder only the user can do. Automation skips it (queue picker, daily prep,
|
||||
// list handler) and the Claude affordances are hidden; a hand-driven ConPTY session is still
|
||||
// allowed. New tasks in a manual list default to true.
|
||||
public bool IsManual { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public string? SessionSkills { get; set; }
|
||||
|
||||
Reference in New Issue
Block a user