Stufe 1 (#764): Agent bearbeiten, Lizenz und Einstellungen in der WebUI

- PUT /api/agents/{id} und /texts: Stammdaten, Grenzen, Budget, Identity/Soul/
  Systemprompt (L6/L7); "Jetzt ausfuehren" in der Agentenansicht (L9)
- Lizenzschluessel ueber die Weboberflaeche (L1): erst vom Lizenzserver
  bestaetigt, dann gespeichert; hebt die Sperre ohne Neustart auf
- GET/PUT /api/settings: Instanz- und Anwendungseinstellungen inkl.
  OpenRouter-Key (L3/L4/L5), Geheimnisse nur schreibend
- GET/PUT /api/verwaltung/settings: Deploymentcenter und Update-Kanal des Managers
- Einstellungsseite zeigt echte Werte statt fester Texte
- NonInteractiveLicensePrompt verwies auf --license-set-key, den es nie gab
- Sprachpakete werden bei jedem Lauf geprueft (LanguagePackTests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 13:30:20 +02:00
co-authored by Claude Opus 5.5
parent a9ce72abe2
commit 8552755bb6
20 changed files with 1677 additions and 322 deletions
@@ -13,6 +13,26 @@ public sealed record NewAgent(
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.
///
@@ -191,6 +211,80 @@ public sealed class AgentAdministration(AppHost host)
+ $"'{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.
///