Files
ClawdDotNet/src/ClawdDotNet.App/Services/AgentAdministration.cs
T
RichardandClaude Opus 5 b78e5d844f InstanceDirectoryManager nach Core verschoben (Vorarbeit #528 Stufe 3)
Reiner Umzug, keine Verhaltensaenderung. Der Instanzmanager braucht die
Verzeichnisstruktur einer Instanz - anlegen, auflisten, Konfiguration lesen
-, aber er darf ClawdDotNet.App nicht referenzieren: Dort haengen sechzehn
Werkzeugprojekte dran (Telegram, Mail, FTP, Rocket.Chat, ...), und der
Manager soll ausdruecklich keines davon kennen. Ein Projektverweis wuerde
sie nicht ausfuehren, aber laden - und die Zusage "keine Werkzeuge im
Manager" waere nur noch eine Absichtserklaerung statt einer Eigenschaft des
Bauplans.

Das Wissen selbst gehoert ohnehin nach Core: Es beschreibt, wie eine Instanz
auf der Platte liegt, und InstanceConfig und AgentConfig liegen dort schon.

Verschoben nach Core/Instances: InstanceDirectoryManager, InstanceInfo,
TokenUsageRecord/TokenUsageFile, AgentListItem/AgentListFile. In
ClawdDotNet.App/Models bleiben JobHistoryEntry und die
Werkzeug-Ansichtsmodelle, die dort richtig liegen.

Build ohne Warnungen, 717 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:17:46 +02:00

263 lines
11 KiB
C#

using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
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 = "");
/// <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"
];
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.");
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;
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>
/// 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)
{
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>
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}'.");
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()
{
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>
private 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);
}
}