using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Staging;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// Die Angaben, die einen neuen Agenten ausmachen.
public sealed record NewAgent(
string AgentId,
string DisplayName,
string Model,
string Description = "",
string? UnitId = null);
///
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
///
/// Diese Regeln standen bis zum 2026-09-06 in
/// ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs — 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.
///
/// Alles, was schreibt, geht über — nur so
/// erfährt die jeweils andere Oberfläche davon.
///
public sealed class AgentAdministration(AppHost host)
{
///
/// Die Werkzeuge, die eine Oberfläche zum Ankreuzen anbietet.
///
/// 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 : dort bleibt die Beschreibung leer.
///
///
public static readonly IReadOnlyList KnownTools =
[
"FileRW", "Telegram", "Mail", "Database", "FTP",
"DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
];
public IReadOnlyList All => host.Instance.Agents;
public AgentConfig? Find(string agentId)
=> host.Instance.Agents.FirstOrDefault(
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase));
///
/// Beschreibungen der tatsächlich geladenen Werkzeuge, nach Namen. Für ein Werkzeug
/// aus , das hier fehlt, bleibt die Beschreibung leer.
///
public IReadOnlyDictionary ToolDescriptions()
=> host.Tools.GetAll()
.GroupBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Description, StringComparer.OrdinalIgnoreCase);
///
/// Legt einen Agenten an — mit Verzeichnis.
///
/// 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.
///
/// Das Verzeichnis ist keine Beigabe, sondern der Ort, an dem ein Agent
/// wohnt. LoadInstanceConfig leert beim Start die Agentenliste und baut
/// sie aus den Ordnern Agents/Agent-* wieder auf — die Liste in
/// InstanceSettings.json 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.
///
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.");
}
///
/// Nimmt den Agenten aus dem Betrieb.
///
/// Sein Verzeichnis wird umbenannt, nicht gelöscht — es heißt danach
/// Entfernt-… 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.
///
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)}'.");
}
///
/// Lässt ein Werkzeug zu oder nimmt es weg.
///
/// 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.
///
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();
}
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.");
}
///
/// Schreibt die Einstellungen eines Werkzeugs.
///
/// Einstellungen zu schreiben heißt, das Werkzeug zu wollen: Es wird dabei
/// zugelassen, falls es das noch nicht war.
///
///
/// Schreibt die Rechte eines Werkzeugs (#641): was ohne Rueckfrage laeuft
/// () und was gar nicht erst vorgelegt wird
/// (). Alles andere wird einzeln zur Freigabe vorgelegt.
/// null entfernt die Liste - dann gilt wieder die
/// Vorgabe des Werkzeugs.
///
public AdminResult SetToolRights(
string agentId, string toolName, IReadOnlyCollection? erlaubt, IReadOnlyCollection 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 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.");
}
///
/// #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.
///
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.");
}
///
/// #660: Sets how long the prompt history of an agent is kept, in days
/// (0 = never record, -1 = forever, null = instance default).
///
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)}.");
}
///
/// #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.
///
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;
}
///
/// #712: The instance's Claude Code account. Keys are only ever replaced, never read
/// back; an empty value keeps the one there is.
///
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.");
}
/// #660: Instance default retention of the prompt history, in days.
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)}.");
}
public AdminResult SetToolSettings(
string agentId, string toolName, Dictionary 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.");
}
///
/// Schreibt die Instanzkonfiguration, nachdem der Aufrufer die
/// -Objekte selbst verändert hat.
///
/// 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.
///
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));
}
///
/// Schreibt einen Agenten dorthin, wo er beim nächsten Start gelesen wird.
///
/// Das ist sein eigener Ordner, nicht InstanceSettings.json.
/// LoadInstanceConfig leert die Agentenliste und baut sie aus
/// Agents/Agent-*/AgentSettings.json wieder auf. Bis zum 2026-09-06 rief
/// niemand im laufenden Betrieb SaveAgentSettings — jede Änderung an Modell,
/// Prompt, Budget oder Werkzeugen stand nur in InstanceSettings.json und war
/// nach dem nächsten Start weg. Es sah aus wie Speichern und war keines.
///
/// Identity und Soul gehen in ihre eigenen Dateien: Von dort
/// werden sie gelesen, und was in AgentSettings.json danebenstünde, überschriebe
/// der nächste Ladevorgang ohnehin.
///
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);
}
}