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); /// /// #764: What can be changed on an existing agent. Every field is optional — null keeps /// the current value, so a form that only knows some of the fields cannot blank the others. /// 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); /// /// 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" ]; /// /// #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. /// public IReadOnlyList 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 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)}'."); } /// The values understands. public static readonly IReadOnlyList PromptCachingModes = ["auto", "on", "off"]; /// The values understands. public static readonly IReadOnlyList WhenExhaustedModes = ["expire", "catchUpOnce"]; /// /// #764: Changes an agent's master data, limits and prompt texts — what the desktop agent page /// edited directly on the configuration objects. /// /// Everything is checked before anything is written. A request with one bad value /// changes nothing: half an update that says "failed" would leave the agent in a state nobody /// asked for. /// /// The display name changes the name only, not the folder: the agent keeps living in /// , which is where writes. /// 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; } /// /// 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)}."); } /// /// #764: The settings form of a tool for one agent () — /// also for a tool the agent does not have yet, so one can see what there is to set. /// public IReadOnlyList? ToolSettingsForm(string agentId, string toolName) => Find(agentId) is { } agent ? ToolSettingsForms.Describe(toolName, agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary()) : null; /// /// #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 /// ). /// public AdminResult ApplyToolSettingsForm(string agentId, string toolName, IReadOnlyDictionary input) { if (Find(agentId) is not { } agent) return AdminResult.Failure($"No agent with id '{agentId}'."); var current = agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary(); 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 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); } }