SetListConfig previously could only pass VerifyCommand through unchanged; only the UI hub could set it. Add an optional verifyCommand parameter with matching clear/merge/create-vs-delete handling, and split the list result into ListConfigDto so the task-only SetTaskConfig/GetTaskConfig DTOs stay untouched. Also corrects Worker/CLAUDE.md's claim that tasks can override verify_command — it's list-only.
218 lines
12 KiB
C#
218 lines
12 KiB
C#
using System.ComponentModel;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Runner;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ModelContextProtocol.Server;
|
|
|
|
namespace ClaudeDo.Worker.External;
|
|
|
|
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns);
|
|
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns, string? VerifyCommand);
|
|
public sealed record TaskConfigResult(bool Found, TaskConfigDto? Config);
|
|
public sealed record ListConfigResult(bool Found, ListConfigDto? Config);
|
|
public sealed record SetListConfigResult(bool Ok, string ListId, ListConfigDto? Config);
|
|
public sealed record SetTaskConfigResult(bool Ok, string TaskId, TaskConfigDto? Config);
|
|
|
|
public sealed record EffectiveModelDto(string Value, string Source);
|
|
public sealed record EffectiveMaxTurnsDto(int Effective, string Source, int Requested, bool Clamped);
|
|
public sealed record EffectiveAgentPathDto(string? Value, string? Source);
|
|
public sealed record EffectiveSystemPromptDto(bool Set, IReadOnlyList<string> Sources);
|
|
public sealed record EffectiveRunConfigDto(
|
|
string TaskId,
|
|
EffectiveModelDto Model,
|
|
EffectiveMaxTurnsDto MaxTurns,
|
|
string Effort,
|
|
string PermissionMode,
|
|
EffectiveAgentPathDto AgentPath,
|
|
EffectiveSystemPromptDto SystemPrompt,
|
|
IReadOnlyList<string> SkillNames);
|
|
|
|
[McpServerToolType]
|
|
public sealed class ConfigMcpTools
|
|
{
|
|
private readonly ListRepository _lists;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
|
|
public ConfigMcpTools(
|
|
ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
|
{
|
|
_lists = lists;
|
|
_tasks = tasks;
|
|
_broadcaster = broadcaster;
|
|
_dbFactory = dbFactory;
|
|
}
|
|
|
|
[McpServerTool, Description("Read a list's default run config, including its verify command — the fallback used by tasks in this list that don't set their own overrides. Returns { found: false, config: null } if none is set.")]
|
|
public async Task<ListConfigResult> GetListConfig(string listId, CancellationToken cancellationToken)
|
|
{
|
|
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
|
|
return cfg is null
|
|
? new ListConfigResult(false, null)
|
|
: new ListConfigResult(true, new ListConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns, cfg.VerifyCommand));
|
|
}
|
|
|
|
// clearFields (not a per-field sentinel like UpdateList's "" for workingDir) because maxTurns is an
|
|
// int? — there's no blank value to overload as "clear" for a number the way there is for a string.
|
|
private static readonly string[] ConfigFieldNames = { "model", "systemPrompt", "agentPath", "maxTurns" };
|
|
private static readonly string[] ListConfigFieldNames = { "model", "systemPrompt", "agentPath", "maxTurns", "verifyCommand" };
|
|
|
|
private static HashSet<string> NormalizeClearFields(IReadOnlyList<string>? clearFields, string[] validFields)
|
|
{
|
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var field in clearFields ?? Array.Empty<string>())
|
|
{
|
|
if (!validFields.Contains(field, StringComparer.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException(
|
|
$"Unknown clearFields entry '{field}'. Valid values: {string.Join(", ", validFields)}.");
|
|
set.Add(field);
|
|
}
|
|
return set;
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Set a list's default model/system prompt/agent path/max turns/verify command — the fallback for tasks " +
|
|
"in this list that don't override them (verify command is list-only; tasks cannot override it). Only " +
|
|
"the fields you pass are changed; omitted fields keep their current value. To clear a field instead, " +
|
|
"name it in clearFields; clearing all five deletes the list's config unless it also carries settings " +
|
|
"this tool doesn't expose (session skills, file-scope serialization), which are always preserved.")]
|
|
public async Task<SetListConfigResult> SetListConfig(
|
|
string listId, string? model = null, string? systemPrompt = null, string? agentPath = null,
|
|
int? maxTurns = null, string? verifyCommand = null,
|
|
[Description("Field names to explicitly clear: 'model', 'systemPrompt', 'agentPath', 'maxTurns', " +
|
|
"'verifyCommand'. A field named here is cleared even if a value was also passed for it.")]
|
|
IReadOnlyList<string>? clearFields = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
|
?? throw new InvalidOperationException($"List {listId} not found.");
|
|
|
|
var clear = NormalizeClearFields(clearFields, ListConfigFieldNames);
|
|
var existing = await _lists.GetConfigAsync(listId, cancellationToken);
|
|
|
|
var m = clear.Contains("model") ? null : model.NullIfBlank() ?? existing?.Model;
|
|
var sp = clear.Contains("systemPrompt") ? null : systemPrompt.NullIfBlank() ?? existing?.SystemPrompt;
|
|
var ap = clear.Contains("agentPath") ? null : agentPath.NullIfBlank() ?? existing?.AgentPath;
|
|
var mt = clear.Contains("maxTurns") ? null : maxTurns ?? existing?.MaxTurns;
|
|
var vc = clear.Contains("verifyCommand") ? null : verifyCommand.NullIfBlank() ?? existing?.VerifyCommand;
|
|
|
|
// Fields this tool doesn't expose but that live on the same row. They must survive every
|
|
// write here — ListRepository.SetConfigAsync copies the entity verbatim, so anything left
|
|
// at its default would silently reset (SerializeOnFileOverlap in particular has no UI
|
|
// affordance at all, so a reset is invisible until tasks stop serializing).
|
|
var hasUnrelatedSettings = existing is not null
|
|
&& (existing.SessionSkills is not null || existing.SerializeOnFileOverlap);
|
|
|
|
ListConfigDto? config;
|
|
var allCleared = m is null && sp is null && ap is null && mt is null && vc is null;
|
|
if (allCleared && clear.Count > 0 && !hasUnrelatedSettings)
|
|
{
|
|
await _lists.DeleteConfigAsync(listId, cancellationToken);
|
|
config = null;
|
|
}
|
|
else if (allCleared && existing is null)
|
|
{
|
|
config = null;
|
|
}
|
|
else
|
|
{
|
|
await _lists.SetConfigAsync(new ListConfigEntity
|
|
{
|
|
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, MaxTurns = mt,
|
|
VerifyCommand = vc, SessionSkills = existing?.SessionSkills,
|
|
SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false,
|
|
}, cancellationToken);
|
|
config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc);
|
|
}
|
|
|
|
await _broadcaster.ListUpdated(listId);
|
|
return new SetListConfigResult(true, listId, config);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Set per-task overrides for model/system prompt/agent path/max turns; these take precedence over the " +
|
|
"list's default config for this one task. Only the fields you pass are changed; omitted fields keep " +
|
|
"their current override. To clear a field instead, name it in clearFields.")]
|
|
public async Task<SetTaskConfigResult> SetTaskConfig(
|
|
string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null,
|
|
int? maxTurns = null,
|
|
[Description("Field names to explicitly clear: 'model', 'systemPrompt', 'agentPath', 'maxTurns'. " +
|
|
"A field named here is cleared even if a value was also passed for it.")]
|
|
IReadOnlyList<string>? clearFields = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
var clear = NormalizeClearFields(clearFields, ConfigFieldNames);
|
|
|
|
var m = clear.Contains("model") ? null : model.NullIfBlank() ?? task.Model;
|
|
var sp = clear.Contains("systemPrompt") ? null : systemPrompt.NullIfBlank() ?? task.SystemPrompt;
|
|
var ap = clear.Contains("agentPath") ? null : agentPath.NullIfBlank() ?? task.AgentPath;
|
|
var mt = clear.Contains("maxTurns") ? null : maxTurns ?? task.MaxTurns;
|
|
|
|
await _tasks.UpdateAgentSettingsAsync(taskId, m, sp, ap, mt, task.SessionSkills, cancellationToken);
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return new SetTaskConfigResult(true, taskId, new TaskConfigDto(m, sp, ap, mt));
|
|
}
|
|
|
|
[McpServerTool, Description("Read this task's per-task overrides (model/system prompt/agent path/max turns), which take precedence over the list's default config. Returns { found: false, config: null } if none is set.")]
|
|
public async Task<TaskConfigResult> GetTaskConfig(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Model is null && task.SystemPrompt is null && task.AgentPath is null && task.MaxTurns is null)
|
|
return new TaskConfigResult(false, null);
|
|
return new TaskConfigResult(true, new TaskConfigDto(task.Model, task.SystemPrompt, task.AgentPath, task.MaxTurns));
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Report the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent " +
|
|
"path, whether a system prompt is set, and skill names — each tagged with its source " +
|
|
"(task/list/preset/global). Use this over get_task_config/get_app_settings when you need resolved " +
|
|
"values, not raw overrides. maxTurns also reports the raw requested value and whether it was clamped " +
|
|
"to the global ceiling.")]
|
|
public async Task<EffectiveRunConfigDto> GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var listConfig = await _lists.GetConfigAsync(task.ListId, cancellationToken);
|
|
|
|
AppSettingsEntity global;
|
|
using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
|
|
global = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
|
|
|
var systemFile = PromptFiles.ReadOrDefault(PromptKind.System);
|
|
var isImprovementChild = task.ParentTaskId is not null && task.CreatedBy == task.ParentTaskId;
|
|
var improvementPrompt = isImprovementChild ? PromptFiles.ReadOrDefault(PromptKind.ImprovementChild) : null;
|
|
|
|
var effective = EffectiveRunConfigResolver.Resolve(task, listConfig, global, systemFile, improvementPrompt);
|
|
|
|
var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills);
|
|
var skillNames = requestedSkills;
|
|
if (requestedSkills.Count > 0)
|
|
{
|
|
List<SessionSkillEntity> installed;
|
|
using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
|
|
installed = (await new SessionSkillRepository(ctx).ListAsync(cancellationToken)).ToList();
|
|
var installedNames = installed.Select(s => s.Name).ToHashSet(StringComparer.Ordinal);
|
|
skillNames = TaskRunner.FilterToInstalled(requestedSkills, installedNames);
|
|
}
|
|
|
|
return new EffectiveRunConfigDto(
|
|
taskId,
|
|
new EffectiveModelDto(effective.Model, effective.ModelSource),
|
|
new EffectiveMaxTurnsDto(effective.MaxTurns, effective.MaxTurnsSource, effective.RequestedMaxTurns, effective.MaxTurnsClamped),
|
|
effective.Effort,
|
|
effective.PermissionMode,
|
|
new EffectiveAgentPathDto(effective.AgentPath, effective.AgentPathSource),
|
|
new EffectiveSystemPromptDto(effective.SystemPromptSet, effective.SystemPromptSources),
|
|
skillNames);
|
|
}
|
|
}
|