- Werkzeugformulare aus den typisierten Einstellungsklassen (L8): Geheimnisse
nur schreibend, nur geaenderte Schluessel werden ersetzt, Rechte bleiben
- Database.allowedTables las ein JSON-Array als rohen Text und haette es beim
naechsten Speichern zerlegt
- Automatische Sicherung der Instanz in der Sicherungsansicht (L10)
- Sicherung hochladen und als neue Instanz einspielen (L11): Pfade der alten
Maschine werden zurueckgesetzt, eine Kopie neben dem Original bekommt eine
eigene ID, Autostart aus; Upload bis 4 GB
- api.js schickte eine Datei als "{}"; Dialoge schliessen beim Seitenwechsel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
608 lines
28 KiB
C#
608 lines
28 KiB
C#
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Instances;
|
|
using ClawdDotNet.Core.Staging;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClawdDotNet.App.Services;
|
|
|
|
/// <summary>Die Angaben, die einen neuen Agenten ausmachen.</summary>
|
|
public sealed record NewAgent(
|
|
string AgentId,
|
|
string DisplayName,
|
|
string Model,
|
|
string Description = "",
|
|
string? UnitId = null);
|
|
|
|
/// <summary>
|
|
/// #764: What can be changed on an existing agent. Every field is optional — <c>null</c> keeps
|
|
/// the current value, so a form that only knows some of the fields cannot blank the others.
|
|
/// </summary>
|
|
public sealed record AgentUpdate(
|
|
string? DisplayName = null,
|
|
string? Model = null,
|
|
string? Description = null,
|
|
string? SystemPrompt = null,
|
|
string? Identity = null,
|
|
string? Soul = null,
|
|
string? PromptCaching = null,
|
|
int? MaxToolResultChars = null,
|
|
int? MaxSteps = null,
|
|
int? MaxContextTokens = null,
|
|
int? TimeoutSeconds = null,
|
|
decimal? DailyCostUsd = null,
|
|
long? DailyTokens = null,
|
|
string? WhenExhausted = null);
|
|
|
|
/// <summary>
|
|
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
|
|
///
|
|
/// <para>Diese Regeln standen bis zum 2026-09-06 in
|
|
/// <c>ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs</c> — die Prüfung auf
|
|
/// doppelte IDs, das Anlegen, das Speichern. Dort war es nicht falsch, solange es genau
|
|
/// eine Oberfläche gab. Mit der Weboberfläche daneben wäre jede dieser Regeln ein
|
|
/// zweites Mal geschrieben worden, und ab dem Tag danach wären beide Fassungen
|
|
/// auseinandergelaufen.</para>
|
|
///
|
|
/// <para>Alles, was schreibt, geht über <see cref="AppHost.SaveInstance"/> — nur so
|
|
/// erfährt die jeweils andere Oberfläche davon.</para>
|
|
/// </summary>
|
|
public sealed class AgentAdministration(AppHost host)
|
|
{
|
|
/// <summary>
|
|
/// Die Werkzeuge, die eine Oberfläche zum Ankreuzen anbietet.
|
|
///
|
|
/// <para>Fest verdrahtet und länger als das, was in einer Instanz tatsächlich
|
|
/// registriert ist: Ein Werkzeug soll auch dann in der Liste stehen, wenn es in
|
|
/// dieser Instanz gerade nicht geladen ist — sonst verschwände mit dem Werkzeug
|
|
/// stillschweigend auch seine Konfiguration aus der Ansicht. Welche davon wirklich
|
|
/// da sind, sagt <see cref="ToolDescriptions"/>: dort bleibt die Beschreibung leer.
|
|
/// </para>
|
|
/// </summary>
|
|
public static readonly IReadOnlyList<string> KnownTools =
|
|
[
|
|
"FileRW", "Telegram", "Mail", "Database", "FTP",
|
|
"DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
|
|
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
|
|
];
|
|
|
|
/// <summary>
|
|
/// #197: What an agent can be given: the known tools and one per external MCP server — also
|
|
/// a server's tool an agent still has after the server was removed, so it can be taken away.
|
|
/// </summary>
|
|
public IReadOnlyList<string> ToolCatalog()
|
|
=> KnownTools
|
|
.Concat(host.Instance.McpServers.Select(s => Core.Mcp.McpServerTool.NameFor(s.Id)))
|
|
.Concat(host.Instance.Agents.SelectMany(a => a.Tools.Keys).Where(k => k.StartsWith(Core.Mcp.McpServerTool.Prefix, StringComparison.OrdinalIgnoreCase)))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
public IReadOnlyList<AgentConfig> All => host.Instance.Agents;
|
|
|
|
public AgentConfig? Find(string agentId)
|
|
=> host.Instance.Agents.FirstOrDefault(
|
|
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase));
|
|
|
|
/// <summary>
|
|
/// Beschreibungen der tatsächlich geladenen Werkzeuge, nach Namen. Für ein Werkzeug
|
|
/// aus <see cref="KnownTools"/>, das hier fehlt, bleibt die Beschreibung leer.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, string> ToolDescriptions()
|
|
=> host.Tools.GetAll()
|
|
.GroupBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(g => g.Key, g => g.First().Description, StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// Legt einen Agenten an — <b>mit Verzeichnis</b>.
|
|
///
|
|
/// <para>Die ID wird ohne Rücksicht auf Groß- und Kleinschreibung geprüft. Zwei
|
|
/// Agenten, die sich nur darin unterscheiden, wären auf der Zielplattform zwar zwei
|
|
/// verschiedene Ordner, aber für jeden Menschen derselbe Agent.</para>
|
|
///
|
|
/// <para><b>Das Verzeichnis ist keine Beigabe, sondern der Ort, an dem ein Agent
|
|
/// wohnt.</b> <c>LoadInstanceConfig</c> leert beim Start die Agentenliste und baut
|
|
/// sie aus den Ordnern <c>Agents/Agent-*</c> wieder auf — die Liste in
|
|
/// <c>InstanceSettings.json</c> wird dabei nicht gelesen. Ein Agent, der nur dort
|
|
/// steht, ist nach dem nächsten Start weg. Genau das tat die Oberfläche bis zum
|
|
/// 2026-09-06.</para>
|
|
/// </summary>
|
|
public AdminResult Add(NewAgent request)
|
|
{
|
|
var agentId = request.AgentId.Trim();
|
|
var displayName = request.DisplayName.Trim();
|
|
|
|
if (agentId.Length == 0)
|
|
return AdminResult.Failure("Ohne Agenten-ID geht es nicht.");
|
|
|
|
if (displayName.Length == 0)
|
|
return AdminResult.Failure("Ohne Anzeigenamen geht es nicht.");
|
|
|
|
if (Find(agentId) is not null)
|
|
return AdminResult.Failure($"Ein Agent mit der ID '{agentId}' existiert bereits.");
|
|
|
|
var unitId = string.IsNullOrWhiteSpace(request.UnitId) ? null : request.UnitId.Trim();
|
|
if (unitId is not null && host.Organisation.Current().Unit(unitId) is null)
|
|
return AdminResult.Failure($"There is no unit '{unitId}'.");
|
|
|
|
string agentDir;
|
|
|
|
try
|
|
{
|
|
// Legt Ordner, AgentSettings.json, Identity.md, Soul.md und den Eintrag in
|
|
// AgentList.json an. Wirft, wenn es den Ordner schon gibt — zwei Agenten mit
|
|
// verschiedener ID, aber gleichem Anzeigenamen teilten sich sonst ein
|
|
// Verzeichnis.
|
|
agentDir = host.Directories.CreateAgent(
|
|
host.InstancePath, displayName, request.Description);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return AdminResult.Failure($"Verzeichnis konnte nicht angelegt werden: {ex.Message}");
|
|
}
|
|
|
|
var config = host.Directories.LoadAgentConfig(agentDir);
|
|
|
|
// CreateAgent leitet die ID aus dem Namen ab; hier zählt die, nach der gefragt
|
|
// wurde.
|
|
config.AgentId = agentId;
|
|
config.DisplayName = displayName;
|
|
config.Model = request.Model;
|
|
config.Description = request.Description;
|
|
|
|
// #683: Placed into a unit, the agent starts with the unit's defaults.
|
|
if (unitId is not null)
|
|
{
|
|
config.UnitId = unitId;
|
|
config.UnitSinceUtc = DateTime.UtcNow;
|
|
host.Organisation.ApplyDefaultsToNewAgent(config);
|
|
}
|
|
|
|
host.Directories.SaveAgentSettings(agentDir, config);
|
|
|
|
host.Instance.Agents.Add(config);
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
|
|
|
return AdminResult.Success($"Agent '{displayName}' angelegt.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nimmt den Agenten aus dem Betrieb.
|
|
///
|
|
/// <para><b>Sein Verzeichnis wird umbenannt, nicht gelöscht</b> — es heißt danach
|
|
/// <c>Entfernt-…</c> und wird beim Start nicht mehr eingelesen. Nur aus der
|
|
/// Instanzliste zu streichen genügte nicht: Beim nächsten Start stünde der Agent
|
|
/// wieder da, weil sein Ordner noch da ist. Ihn zu löschen wäre die andere
|
|
/// Übertreibung — im Arbeitsordner steht seine Arbeit.</para>
|
|
/// </summary>
|
|
public AdminResult Remove(string agentId)
|
|
{
|
|
if (Find(agentId) is not { } agent)
|
|
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
|
|
|
host.Instance.Agents.Remove(agent);
|
|
|
|
var folder = agent.AgentDir.Length > 0
|
|
? Path.GetFileName(agent.AgentDir)
|
|
: InstanceDirectoryManager.BuildAgentFolderName(agent.DisplayName);
|
|
|
|
string? archived = null;
|
|
|
|
try
|
|
{
|
|
archived = host.Directories.ArchiveAgent(host.InstancePath, folder);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Der Agent ist aus der Liste; dass sein Ordner liegen bleibt, ist ein
|
|
// Ärgernis und kein Grund, den Aufruf scheitern zu lassen — sonst stünde er
|
|
// halb entfernt da. Beim nächsten Start käme er allerdings wieder, und genau
|
|
// das muss in der Meldung stehen.
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
|
|
|
return AdminResult.Failure(
|
|
$"Agent '{agentId}' aus der Liste genommen, aber sein Verzeichnis ließ sich "
|
|
+ $"nicht beiseiteräumen ({ex.Message}). Beim nächsten Start ist er wieder da.");
|
|
}
|
|
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
|
|
|
return AdminResult.Success(archived is null
|
|
? $"Agent '{agentId}' entfernt."
|
|
: $"Agent '{agentId}' entfernt. Sein Verzeichnis liegt unter "
|
|
+ $"'{Path.GetFileName(archived)}'.");
|
|
}
|
|
|
|
/// <summary>The values <see cref="AgentConfig.PromptCaching"/> understands.</summary>
|
|
public static readonly IReadOnlyList<string> PromptCachingModes = ["auto", "on", "off"];
|
|
|
|
/// <summary>The values <see cref="AgentBudget.WhenExhausted"/> understands.</summary>
|
|
public static readonly IReadOnlyList<string> WhenExhaustedModes = ["expire", "catchUpOnce"];
|
|
|
|
/// <summary>
|
|
/// #764: Changes an agent's master data, limits and prompt texts — what the desktop agent page
|
|
/// edited directly on the configuration objects.
|
|
///
|
|
/// <para><b>Everything is checked before anything is written.</b> A request with one bad value
|
|
/// changes nothing: half an update that says "failed" would leave the agent in a state nobody
|
|
/// asked for.</para>
|
|
///
|
|
/// <para>The display name changes the name only, not the folder: the agent keeps living in
|
|
/// <see cref="AgentConfig.AgentDir"/>, which is where <see cref="PersistAgent"/> writes.</para>
|
|
/// </summary>
|
|
public AdminResult Update(string agentId, AgentUpdate update)
|
|
{
|
|
if (Find(agentId) is not { } agent)
|
|
return AdminResult.Failure($"No agent with id '{agentId}'.");
|
|
|
|
if (Validate(update) is { } problem)
|
|
return AdminResult.Failure(problem);
|
|
|
|
if (update.DisplayName is { } name) agent.DisplayName = name.Trim();
|
|
if (update.Model is { } model) agent.Model = model.Trim();
|
|
if (update.Description is { } description) agent.Description = description;
|
|
if (update.SystemPrompt is { } systemPrompt) agent.SystemPrompt = systemPrompt;
|
|
if (update.Identity is { } identity) agent.Identity = identity;
|
|
if (update.Soul is { } soul) agent.Soul = soul;
|
|
if (update.PromptCaching is { } caching) agent.PromptCaching = caching.Trim().ToLowerInvariant();
|
|
if (update.MaxToolResultChars is { } chars) agent.MaxToolResultChars = chars;
|
|
if (update.MaxSteps is { } steps) agent.LoopGuard.MaxSteps = steps;
|
|
if (update.MaxContextTokens is { } context) agent.LoopGuard.MaxContextTokens = context;
|
|
if (update.TimeoutSeconds is { } timeout) agent.LoopGuard.TimeoutSeconds = timeout;
|
|
if (update.DailyCostUsd is { } cost) agent.Budget.DailyCostUsd = cost;
|
|
if (update.DailyTokens is { } tokens) agent.Budget.DailyTokens = tokens;
|
|
if (update.WhenExhausted is { } when)
|
|
agent.Budget.WhenExhausted = WhenExhaustedModes.First(m => m.Equals(when.Trim(), StringComparison.OrdinalIgnoreCase));
|
|
|
|
PersistAgent(agent);
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agent.AgentId));
|
|
|
|
return AdminResult.Success($"Agent '{agent.DisplayName}' saved.");
|
|
}
|
|
|
|
private static string? Validate(AgentUpdate update)
|
|
{
|
|
if (update.DisplayName is { } name && name.Trim().Length == 0)
|
|
return "The display name must not be empty.";
|
|
if (update.Model is { } model && model.Trim().Length == 0)
|
|
return "The model must not be empty.";
|
|
if (update.PromptCaching is { } caching
|
|
&& !PromptCachingModes.Contains(caching.Trim().ToLowerInvariant()))
|
|
return $"Prompt caching is one of {string.Join(", ", PromptCachingModes)}.";
|
|
if (update.WhenExhausted is { } when
|
|
&& !WhenExhaustedModes.Any(m => m.Equals(when.Trim(), StringComparison.OrdinalIgnoreCase)))
|
|
return $"'When exhausted' is one of {string.Join(", ", WhenExhaustedModes)}.";
|
|
if (update.MaxToolResultChars is < 100)
|
|
return "A tool result needs at least 100 characters.";
|
|
if (update.MaxSteps is < 1 or > 500)
|
|
return "Steps must be between 1 and 500.";
|
|
if (update.MaxContextTokens is < 1000)
|
|
return "The context needs at least 1000 tokens.";
|
|
if (update.TimeoutSeconds is < 10 or > 86_400)
|
|
return "The time limit must be between 10 seconds and one day.";
|
|
if (update.DailyCostUsd is < 0)
|
|
return "A daily budget cannot be negative.";
|
|
if (update.DailyTokens is < 0)
|
|
return "A daily token budget cannot be negative.";
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lässt ein Werkzeug zu oder nimmt es weg.
|
|
///
|
|
/// <para>Zulassen legt eine leere Konfiguration an — das Werkzeug läuft dann mit
|
|
/// seinen Vorgabewerten. Wegnehmen entfernt den Eintrag samt Einstellungen; das ist
|
|
/// seit jeher so und der Grund, warum die Oberfläche zwischen „ankreuzen" und
|
|
/// „Einstellungen speichern" unterscheidet.</para>
|
|
/// </summary>
|
|
public AdminResult SetToolEnabled(string agentId, string toolName, bool enabled)
|
|
{
|
|
if (Find(agentId) is not { } agent)
|
|
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
|
|
|
if (enabled)
|
|
{
|
|
// #683: What the unit may not have, a member cannot be granted.
|
|
if (!agent.Tools.ContainsKey(toolName) && host.Organisation.CeilingRefusal(agent, toolName) is { } refused)
|
|
return AdminResult.Failure(refused);
|
|
|
|
if (!agent.Tools.ContainsKey(toolName))
|
|
agent.Tools[toolName] = new Dictionary<string, object?>();
|
|
}
|
|
else
|
|
{
|
|
agent.Tools.Remove(toolName);
|
|
}
|
|
|
|
PersistAgent(agent);
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
|
|
|
return AdminResult.Success(enabled
|
|
? $"Werkzeug '{toolName}' für '{agent.DisplayName}' zugelassen."
|
|
: $"Werkzeug '{toolName}' für '{agent.DisplayName}' entfernt.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Schreibt die Einstellungen eines Werkzeugs.
|
|
///
|
|
/// <para>Einstellungen zu schreiben heißt, das Werkzeug zu wollen: Es wird dabei
|
|
/// zugelassen, falls es das noch nicht war.</para>
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Schreibt die Rechte eines Werkzeugs (#641): was ohne Rueckfrage laeuft
|
|
/// (<paramref name="erlaubt"/>) und was gar nicht erst vorgelegt wird
|
|
/// (<paramref name="verboten"/>). Alles andere wird einzeln zur Freigabe vorgelegt.
|
|
/// <paramref name="erlaubt"/> <c>null</c> entfernt die Liste - dann gilt wieder die
|
|
/// Vorgabe des Werkzeugs.
|
|
/// </summary>
|
|
public AdminResult SetToolRights(
|
|
string agentId, string toolName, IReadOnlyCollection<string>? erlaubt, IReadOnlyCollection<string> verboten)
|
|
{
|
|
if (Find(agentId) is not { } agent)
|
|
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
|
|
|
if (!agent.Tools.TryGetValue(toolName, out var settings))
|
|
return AdminResult.Failure($"Werkzeug '{toolName}' ist '{agent.DisplayName}' nicht zugewiesen.");
|
|
|
|
static string[] Liste(IEnumerable<string> werte) => werte
|
|
.Where(w => !string.IsNullOrWhiteSpace(w))
|
|
.Select(Werkzeugrechte.Normalisiere)
|
|
.Distinct()
|
|
.Order()
|
|
.ToArray();
|
|
|
|
var verbotenListe = Liste(verboten);
|
|
var erlaubtListe = erlaubt is null ? null : Liste(erlaubt).Except(verbotenListe).ToArray();
|
|
|
|
if (erlaubtListe is null) settings.Remove(Werkzeugrechte.ErlaubtSchluessel);
|
|
else settings[Werkzeugrechte.ErlaubtSchluessel] = erlaubtListe;
|
|
|
|
if (verbotenListe.Length == 0) settings.Remove(Werkzeugrechte.VerbotenSchluessel);
|
|
else settings[Werkzeugrechte.VerbotenSchluessel] = verbotenListe;
|
|
|
|
PersistAgent(agent);
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
|
|
|
return AdminResult.Success($"Rechte für '{toolName}' ({agent.DisplayName}) gespeichert.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// #715: Treat the agent as reading foreign content whatever its tools. Can only tighten
|
|
/// the tier: an agent whose tools read foreign content stays in that tier either way.
|
|
/// </summary>
|
|
public AdminResult SetTreatAsForeignReader(string agentId, bool value)
|
|
{
|
|
if (Find(agentId) is not { } agent)
|
|
return AdminResult.Failure($"No agent with id '{agentId}'.");
|
|
|
|
agent.TreatAsForeignReader = value;
|
|
PersistAgent(agent);
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
|
|
|
return AdminResult.Success(value
|
|
? $"'{agent.DisplayName}' is treated as reading outside content."
|
|
: $"'{agent.DisplayName}' is in the tier its tools give it.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// #660: Sets how long the prompt history of an agent is kept, in days
|
|
/// (0 = never record, -1 = forever, <c>null</c> = instance default).
|
|
/// </summary>
|
|
public AdminResult SetPromptHistoryRetention(string agentId, int? retentionDays)
|
|
{
|
|
if (Find(agentId) is not { } agent)
|
|
return AdminResult.Failure($"No agent with id '{agentId}'.");
|
|
|
|
agent.PromptHistory.RetentionDays = retentionDays is { } d
|
|
? Core.Transcripts.PromptHistorySettings.Normalize(d)
|
|
: null;
|
|
|
|
PersistAgent(agent);
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
|
|
|
return AdminResult.Success(retentionDays is null
|
|
? $"Prompt history of '{agent.DisplayName}' follows the instance default."
|
|
: $"Prompt history of '{agent.DisplayName}': {Core.Transcripts.PromptHistorySettings.Describe(retentionDays.Value)}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// #712: The runtime an agent works in. Checked here, not at the next run: a list of
|
|
/// hosts that does not parse or a command-less command runtime would otherwise show up
|
|
/// only as a failed run at night.
|
|
/// </summary>
|
|
public AdminResult SetRuntime(string agentId, Core.Runtimes.AgentRuntimeConfig runtime)
|
|
{
|
|
if (Find(agentId) is not { } agent)
|
|
return AdminResult.Failure($"No agent with id '{agentId}'.");
|
|
if (ValidateRuntime(runtime) is { } problem)
|
|
return AdminResult.Failure(problem);
|
|
|
|
runtime.Kind = Core.Runtimes.RuntimeKinds.Normalize(runtime.Kind);
|
|
runtime.AllowedTools = runtime.AllowedTools.Select(t => t.Trim()).Where(t => t.Length > 0).Distinct().ToList();
|
|
runtime.Egress = runtime.Egress.Select(e => e.Trim()).Where(e => e.Length > 0).Distinct().ToList();
|
|
runtime.Arguments = runtime.Arguments.ToList();
|
|
agent.Runtime = runtime;
|
|
PersistAgent(agent);
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
|
|
|
return AdminResult.Success(runtime.IsForeign
|
|
? $"'{agent.DisplayName}' now works in {runtime.Kind}, in the sandbox."
|
|
: $"'{agent.DisplayName}' works natively again.");
|
|
}
|
|
|
|
public static string? ValidateRuntime(Core.Runtimes.AgentRuntimeConfig runtime)
|
|
{
|
|
if (!Core.Runtimes.RuntimeKinds.IsKnown(runtime.Kind))
|
|
return $"Unknown runtime '{runtime.Kind}' — known are {string.Join(", ", Core.Runtimes.RuntimeKinds.All)}.";
|
|
if (runtime.MaxTurns is < 1 or > 500)
|
|
return "Turns must be between 1 and 500.";
|
|
if (runtime.TimeoutMinutes is < 1 or > 240)
|
|
return "The time limit must be between 1 and 240 minutes.";
|
|
if (Core.Runtimes.RuntimeKinds.Normalize(runtime.Kind) == Core.Runtimes.RuntimeKinds.Command
|
|
&& string.IsNullOrWhiteSpace(runtime.Command))
|
|
return "The command runtime needs a command.";
|
|
// A tool name, possibly with a pattern in parentheses — never an option in disguise.
|
|
if (runtime.AllowedTools.FirstOrDefault(t => t.TrimStart().StartsWith('-') || t.Contains(',')) is { } odd)
|
|
return $"'{odd}' is not a tool name.";
|
|
try
|
|
{
|
|
Core.Sandbox.EgressRules.Parse(runtime.Egress);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return ex.Message;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// #712: The instance's Claude Code account. Keys are only ever replaced, never read
|
|
/// back; an empty value keeps the one there is.
|
|
/// </summary>
|
|
public AdminResult SetClaudeCode(string? executable, string? auth, string? apiKey, string? oauthToken)
|
|
{
|
|
var settings = host.Instance.ClaudeCode;
|
|
var mode = string.IsNullOrWhiteSpace(auth) ? settings.Auth : auth.Trim().ToLowerInvariant();
|
|
if (mode is not (Core.Runtimes.ClaudeCodeSettings.ApiKeyAuth or Core.Runtimes.ClaudeCodeSettings.SubscriptionAuth))
|
|
return AdminResult.Failure($"Unknown sign-in '{auth}'.");
|
|
|
|
settings.Auth = mode;
|
|
if (executable is not null)
|
|
settings.Executable = executable.Trim();
|
|
if (!string.IsNullOrWhiteSpace(apiKey))
|
|
settings.ApiKey = apiKey.Trim();
|
|
if (!string.IsNullOrWhiteSpace(oauthToken))
|
|
settings.OAuthToken = oauthToken.Trim();
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
|
|
|
|
return AdminResult.Success(settings.OnSubscription
|
|
? "Claude Code runs on the subscription."
|
|
: "Claude Code runs on the API key.");
|
|
}
|
|
|
|
/// <summary>#660: Instance default retention of the prompt history, in days.</summary>
|
|
public AdminResult SetDefaultPromptHistoryRetention(int retentionDays)
|
|
{
|
|
host.Instance.PromptHistory.RetentionDays = Core.Transcripts.PromptHistorySettings.Normalize(retentionDays);
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
|
|
|
|
return AdminResult.Success(
|
|
$"Prompt history default: {Core.Transcripts.PromptHistorySettings.Describe(retentionDays)}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// #764: The settings form of a tool for one agent (<see cref="ToolSettingsForms"/>) —
|
|
/// also for a tool the agent does not have yet, so one can see what there is to set.
|
|
/// </summary>
|
|
public IReadOnlyList<ToolSettingField>? ToolSettingsForm(string agentId, string toolName)
|
|
=> Find(agentId) is { } agent
|
|
? ToolSettingsForms.Describe(toolName, agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary<string, object?>())
|
|
: null;
|
|
|
|
/// <summary>
|
|
/// #764: Writes form input from the WebUI into a tool's settings. Secrets left empty keep
|
|
/// their value, keys the form does not know are kept, and the rights stay (see
|
|
/// <see cref="SetToolSettings"/>).
|
|
/// </summary>
|
|
public AdminResult ApplyToolSettingsForm(string agentId, string toolName, IReadOnlyDictionary<string, string?> input)
|
|
{
|
|
if (Find(agentId) is not { } agent)
|
|
return AdminResult.Failure($"No agent with id '{agentId}'.");
|
|
|
|
var current = agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary<string, object?>();
|
|
var (settings, error) = ToolSettingsForms.Apply(toolName, current, input);
|
|
|
|
return settings is null
|
|
? AdminResult.Failure(error ?? "Not saved.")
|
|
: SetToolSettings(agentId, toolName, settings);
|
|
}
|
|
|
|
public AdminResult SetToolSettings(
|
|
string agentId, string toolName, Dictionary<string, object?> settings)
|
|
{
|
|
if (Find(agentId) is not { } agent)
|
|
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
|
|
|
// #683: Settings for a tool the agent does not have yet grant it — the ceiling applies.
|
|
if (!agent.Tools.ContainsKey(toolName) && host.Organisation.CeilingRefusal(agent, toolName) is { } refused)
|
|
return AdminResult.Failure(refused);
|
|
|
|
// #641: Die Einstellungsmasken kennen die Rechte nicht und schreiben ein frisches
|
|
// Woerterbuch. Ohne diese Uebernahme loeschte jedes Speichern still "erlaubt" und
|
|
// "verboten" - und damit die Rechte des Agenten.
|
|
if (agent.Tools.TryGetValue(toolName, out var bisher))
|
|
{
|
|
foreach (var schluessel in new[] { Werkzeugrechte.ErlaubtSchluessel, Werkzeugrechte.VerbotenSchluessel })
|
|
{
|
|
if (!settings.ContainsKey(schluessel) && bisher.TryGetValue(schluessel, out var wert))
|
|
settings[schluessel] = wert;
|
|
}
|
|
}
|
|
|
|
agent.Tools[toolName] = settings;
|
|
|
|
PersistAgent(agent);
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
|
|
|
return AdminResult.Success($"Einstellungen für '{toolName}' gespeichert.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Schreibt die Instanzkonfiguration, nachdem der Aufrufer die
|
|
/// <see cref="AgentConfig"/>-Objekte selbst verändert hat.
|
|
///
|
|
/// <para>Für die Avalonia-Oberfläche, deren Ansichtsmodelle über Bindungen direkt auf
|
|
/// den Konfigurationsobjekten arbeiten und am Ende nur noch speichern wollen. Die
|
|
/// Weboberfläche hat diesen Fall nicht — sie schickt ganze Felder und sollte die
|
|
/// Aufrufe oben nehmen.</para>
|
|
/// </summary>
|
|
public void Save()
|
|
{
|
|
// #683: The desktop toggles tools directly on the configuration objects and only
|
|
// saves here. The ceiling is a rule of writing, so it is enforced here as well: a
|
|
// tool beyond it does not get saved.
|
|
var chart = host.Organisation.Current();
|
|
foreach (var agent in host.Instance.Agents)
|
|
{
|
|
if (chart.ToolCeiling(agent.UnitId) is not { } ceiling)
|
|
continue;
|
|
foreach (var tool in agent.Tools.Keys.Where(t => !ceiling.Contains(t)).ToList())
|
|
{
|
|
agent.Tools.Remove(tool);
|
|
host.LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation").LogWarning(
|
|
"Tool {Tool} removed from {AgentId}: beyond the tool ceiling of its unit", tool, agent.AgentId);
|
|
}
|
|
}
|
|
|
|
foreach (var agent in host.Instance.Agents)
|
|
PersistAgent(agent);
|
|
|
|
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Schreibt einen Agenten dorthin, wo er beim nächsten Start gelesen wird.
|
|
///
|
|
/// <para><b>Das ist sein eigener Ordner, nicht <c>InstanceSettings.json</c>.</b>
|
|
/// <c>LoadInstanceConfig</c> leert die Agentenliste und baut sie aus
|
|
/// <c>Agents/Agent-*/AgentSettings.json</c> wieder auf. Bis zum 2026-09-06 rief
|
|
/// niemand im laufenden Betrieb <c>SaveAgentSettings</c> — jede Änderung an Modell,
|
|
/// Prompt, Budget oder Werkzeugen stand nur in <c>InstanceSettings.json</c> und war
|
|
/// nach dem nächsten Start weg. Es sah aus wie Speichern und war keines.</para>
|
|
///
|
|
/// <para><c>Identity</c> und <c>Soul</c> gehen in ihre eigenen Dateien: Von dort
|
|
/// werden sie gelesen, und was in <c>AgentSettings.json</c> danebenstünde, überschriebe
|
|
/// der nächste Ladevorgang ohnehin.</para>
|
|
/// </summary>
|
|
internal void PersistAgent(AgentConfig agent)
|
|
{
|
|
// Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten
|
|
// Instanzdatei. Ihm hier einen Ordner anzulegen waere eine Nebenwirkung des
|
|
// Speicherns; er bleibt, wie er ist.
|
|
if (string.IsNullOrWhiteSpace(agent.AgentDir) || !Directory.Exists(agent.AgentDir))
|
|
return;
|
|
|
|
host.Directories.SaveAgentSettings(agent.AgentDir, agent);
|
|
host.Directories.SaveAgentIdentity(agent.AgentDir, agent.Identity);
|
|
host.Directories.SaveAgentSoul(agent.AgentDir, agent.Soul);
|
|
}
|
|
}
|