Files
ClawdDotNet/Services/InstanceDirectoryManager.cs
T
RichardandClaude Opus 4.8 eae13771cf B1, B14 und B3 beheben: Compaction-Paarung und Token-Semantik
B1 — Die Compaction behielt blind die letzten 6 Nachrichten. Fiel diese Grenze
mitten in eine Tool-Sequenz, entstand eine tool-Antwort ohne zugehoerigen
assistant-tool_call; die API lehnt das mit HTTP 400 ab. FindSafeTailStart
verschiebt die Grenze jetzt rueckwaerts auf eine Blockgrenze.

B14 — Bei Konversationen mit hoechstens 6 Nachrichten enthielt der Tail auch die
system-Nachricht, die anschliessend ein zweites Mal angehaengt wurde. Ergebnis
war ein doppelter System-Prompt und eine duplizierte Konversation — die
Compaction vergroesserte den Kontext, statt ihn zu verkleinern. Der Tail beginnt
nun grundsaetzlich hinter dem System-Prompt; liegt davor nichts Nennenswertes,
wird die Kompaktierung uebersprungen. Gefunden durch den Property-Test.

Nebeneffekt: Zusammengefasst wird nur noch der Teil, der tatsaechlich wegfaellt.
Der Tail bleibt woertlich erhalten und musste bisher doppelt bezahlt werden.

B3 — maxTokens zaehlte kumulativ ueber alle Schritte, wurde aber wie eine
Kontextgrenze konfiguriert. Da jeder Schritt den vollen Kontext erneut sendet,
brach ein Chat mit 20k Kontext nach vier Schritten ab. Aufgeteilt in
maxCumulativeTokens (Kostenbudget, Default 500k) und maxContextTokens
(Kontextgroesse). Alte Konfigurationen werden beim Laden migriert, die
Fehlermeldungen unterscheiden jetzt Schritt- und Kostenlimit.

Alle 31 Tests gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:15:50 +02:00

434 lines
15 KiB
C#

using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Models;
namespace ClawdDotNet.Services;
/// <summary>
/// Verwaltet die gesamte Verzeichnisstruktur für Instanzen und Agenten.
///
/// Layout:
/// {InstancesDir}/
/// ├── Instance-{Name}/
/// │ ├── InstanceSettings.json
/// │ ├── TokenUsage.json
/// │ └── Agents/
/// │ ├── AgentList.json
/// │ └── Agent-{Name}/
/// │ ├── AgentSettings.json
/// │ ├── Soul.md
/// │ ├── Identity.md
/// │ ├── Logs/
/// │ └── Workspace/
/// </summary>
public sealed class InstanceDirectoryManager
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private readonly string _instancesDir;
private readonly Lock _tokenUsageLock = new();
public InstanceDirectoryManager(string instancesDirectory)
{
_instancesDir = Path.GetFullPath(instancesDirectory);
Directory.CreateDirectory(_instancesDir);
}
public string InstancesDirectory => _instancesDir;
// ═══════════════════════════════════════════════════
// INSTANZ-OPERATIONEN
// ═══════════════════════════════════════════════════
public static string BuildInstanceFolderName(string instanceName)
=> $"Instance-{SanitizeName(instanceName)}";
public string GetInstancePath(string instanceName)
=> Path.Combine(_instancesDir, BuildInstanceFolderName(instanceName));
public List<InstanceInfo> ListInstances()
{
var result = new List<InstanceInfo>();
if (!Directory.Exists(_instancesDir))
return result;
foreach (var dir in Directory.GetDirectories(_instancesDir, "Instance-*"))
{
var folderName = Path.GetFileName(dir);
var settingsPath = Path.Combine(dir, "InstanceSettings.json");
var info = new InstanceInfo
{
FolderName = folderName,
FolderPath = dir,
InstanceName = folderName.Replace("Instance-", "")
};
if (File.Exists(settingsPath))
{
try
{
var json = File.ReadAllText(settingsPath);
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts);
if (config is not null)
{
info.InstanceName = config.InstanceName;
info.ApiKeyStatus = string.IsNullOrWhiteSpace(config.OpenRouterApiKey)
? "Fehlt" : "Konfiguriert";
}
}
catch { /* defekte Config → Standardwerte */ }
}
// Agenten zählen
var agentsDir = Path.Combine(dir, "Agents");
if (Directory.Exists(agentsDir))
info.AgentCount = Directory.GetDirectories(agentsDir, "Agent-*").Length;
result.Add(info);
}
return result.OrderBy(i => i.InstanceName).ToList();
}
public string CreateInstance(string instanceName)
{
var instanceDir = GetInstancePath(instanceName);
if (Directory.Exists(instanceDir))
throw new InvalidOperationException($"Instanz '{instanceName}' existiert bereits.");
// Hauptverzeichnis
Directory.CreateDirectory(instanceDir);
// Agents-Unterverzeichnis
var agentsDir = Path.Combine(instanceDir, "Agents");
Directory.CreateDirectory(agentsDir);
// SharedWorkspace-Verzeichnis
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
// InstanceSettings.json
var config = new InstanceConfig
{
InstanceId = Guid.NewGuid().ToString("N")[..8],
InstanceName = instanceName,
LogDirectory = "./Logs",
WorkingDirectory = instanceDir
};
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
// TokenUsage.json (leer)
var tokenUsage = new TokenUsageFile
{
InstanceId = config.InstanceId,
InstanceName = instanceName
};
SaveJson(Path.Combine(instanceDir, "TokenUsage.json"), tokenUsage);
// AgentList.json (leer)
SaveJson(Path.Combine(agentsDir, "AgentList.json"), new AgentListFile());
return instanceDir;
}
public InstanceConfig LoadInstanceConfig(string instanceDir)
{
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
if (!File.Exists(settingsPath))
throw new FileNotFoundException($"InstanceSettings.json nicht gefunden in: {instanceDir}");
var json = File.ReadAllText(settingsPath);
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
// Agenten aus Verzeichnisstruktur laden
config.Agents.Clear();
var agentsDir = Path.Combine(instanceDir, "Agents");
// Sicherstellen, dass SharedWorkspace existiert (Migration)
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
// AgentList.json für Descriptions laden
var agentListPath = Path.Combine(agentsDir, "AgentList.json");
var agentList = File.Exists(agentListPath)
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
: new AgentListFile();
if (Directory.Exists(agentsDir))
{
foreach (var agentDir in Directory.GetDirectories(agentsDir, "Agent-*").OrderBy(d => d))
{
var agent = LoadAgentConfig(agentDir);
var folderName = Path.GetFileName(agentDir);
var listEntry = agentList.Agents.FirstOrDefault(a => a.FolderName == folderName);
if (listEntry is not null && !string.IsNullOrWhiteSpace(listEntry.Description))
agent.Description = listEntry.Description;
config.Agents.Add(agent);
}
}
return config;
}
public void SaveInstanceConfig(string instanceDir, InstanceConfig config)
{
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
SaveJson(settingsPath, config);
}
// ═══════════════════════════════════════════════════
// AGENTEN-OPERATIONEN
// ═══════════════════════════════════════════════════
public static string BuildAgentFolderName(string agentName)
=> $"Agent-{SanitizeName(agentName)}";
public string GetAgentPath(string instanceDir, string agentName)
=> Path.Combine(instanceDir, "Agents", BuildAgentFolderName(agentName));
public string CreateAgent(string instanceDir, string agentName, string description = "")
{
var agentDir = GetAgentPath(instanceDir, agentName);
if (Directory.Exists(agentDir))
throw new InvalidOperationException($"Agent '{agentName}' existiert bereits in dieser Instanz.");
// Verzeichnisse anlegen
Directory.CreateDirectory(agentDir);
Directory.CreateDirectory(Path.Combine(agentDir, "Logs"));
Directory.CreateDirectory(Path.Combine(agentDir, "Workspace"));
// AgentSettings.json
var agentConfig = new AgentConfig
{
AgentId = SanitizeName(agentName).ToLowerInvariant(),
DisplayName = agentName,
Model = "anthropic/claude-sonnet-4-5"
};
SaveAgentSettings(agentDir, agentConfig);
// Identity.md
File.WriteAllText(Path.Combine(agentDir, "Identity.md"),
$"""
# Identity: {agentName}
Du bist **{agentName}**, ein spezialisierter KI-Agent im ClawdDotNet-System.
## Rolle
[Beschreibe hier die Rolle und Verantwortlichkeiten des Agenten]
## Expertise
[Beschreibe hier die Fachgebiete und Fähigkeiten]
## Kontext
[Beschreibe hier den Arbeitskontext und die Teamzugehörigkeit]
""");
// Soul.md
File.WriteAllText(Path.Combine(agentDir, "Soul.md"),
$"""
# Soul: {agentName}
## Persönlichkeit
- Gründlich und zuverlässig
- Klar und präzise in der Kommunikation
- Proaktiv bei der Problemerkennung
## Arbeitsweise
- Analysiere Aufgaben sorgfältig bevor du handelst
- Dokumentiere deine Entscheidungen und Ergebnisse
- Nutze die dir zugewiesenen Tools effizient
## Werte
- Genauigkeit vor Geschwindigkeit
- Transparenz in der Entscheidungsfindung
- Sicherheit und Datenschutz haben Priorität
""");
// AgentList.json aktualisieren
UpdateAgentList(instanceDir, agentName, description, BuildAgentFolderName(agentName));
return agentDir;
}
public AgentConfig LoadAgentConfig(string agentDir)
{
// AgentSettings.json laden
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
AgentConfig config;
if (File.Exists(settingsPath))
{
var json = File.ReadAllText(settingsPath);
config = JsonSerializer.Deserialize<AgentConfig>(json, JsonOpts) ?? new AgentConfig();
ConfigLoader.Migrate(config);
}
else
{
config = new AgentConfig
{
AgentId = Path.GetFileName(agentDir).Replace("Agent-", "").ToLowerInvariant(),
DisplayName = Path.GetFileName(agentDir).Replace("Agent-", "")
};
}
// Identity.md laden
var identityPath = Path.Combine(agentDir, "Identity.md");
if (File.Exists(identityPath))
config.Identity = File.ReadAllText(identityPath);
// Soul.md laden
var soulPath = Path.Combine(agentDir, "Soul.md");
if (File.Exists(soulPath))
config.Soul = File.ReadAllText(soulPath);
// Agent-Verzeichnis merken (stabil auch bei DisplayName-Änderungen)
config.AgentDir = Path.GetFullPath(agentDir);
// Workspace-Pfad setzen
config.WorkspacePath = Path.GetFullPath(Path.Combine(agentDir, "Workspace"));
// SharedWorkspace-Pfad setzen
var agentsDir = Path.GetDirectoryName(agentDir); // Dies ist der /Agents Ordner
if (agentsDir != null)
{
config.SharedWorkspacePath = Path.GetFullPath(Path.Combine(agentsDir, "SharedWorkspace"));
}
return config;
}
public void SaveAgentSettings(string agentDir, AgentConfig config)
{
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
SaveJson(settingsPath, config);
}
public void SaveAgentIdentity(string agentDir, string identity)
{
File.WriteAllText(Path.Combine(agentDir, "Identity.md"), identity);
}
public void SaveAgentSoul(string agentDir, string soul)
{
File.WriteAllText(Path.Combine(agentDir, "Soul.md"), soul);
}
public void RemoveAgent(string instanceDir, string agentFolderName)
{
var agentDir = Path.Combine(instanceDir, "Agents", agentFolderName);
if (Directory.Exists(agentDir))
Directory.Delete(agentDir, recursive: true);
// AgentList.json aktualisieren
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
if (File.Exists(agentListPath))
{
var list = LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile();
list.Agents.RemoveAll(a => a.FolderName == agentFolderName);
SaveJson(agentListPath, list);
}
}
// ═══════════════════════════════════════════════════
// TOKEN USAGE
// ═══════════════════════════════════════════════════
public void AppendTokenUsage(string instanceDir, TokenUsageRecord record)
{
lock (_tokenUsageLock)
{
var path = Path.Combine(instanceDir, "TokenUsage.json");
TokenUsageFile file;
if (File.Exists(path))
{
try
{
file = LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile();
}
catch (JsonException)
{
// Korrupte Datei: Backup erstellen, neu anfangen
var backupPath = path + $".corrupt_{DateTime.Now:yyyyMMdd_HHmmss}";
try { File.Copy(path, backupPath, overwrite: true); } catch { /* best effort */ }
file = new TokenUsageFile();
}
}
else
{
file = new TokenUsageFile();
}
file.Records.Add(record);
SaveJson(path, file);
}
}
public TokenUsageFile LoadTokenUsage(string instanceDir)
{
var path = Path.Combine(instanceDir, "TokenUsage.json");
return File.Exists(path)
? LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile()
: new TokenUsageFile();
}
// ═══════════════════════════════════════════════════
// HILFSMETHODEN
// ═══════════════════════════════════════════════════
private void UpdateAgentList(string instanceDir, string agentName, string description, string folderName)
{
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
var list = File.Exists(agentListPath)
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
: new AgentListFile();
// Duplikat-Check
if (list.Agents.All(a => a.FolderName != folderName))
{
list.Agents.Add(new AgentListItem
{
Name = agentName,
Description = description,
FolderName = folderName
});
}
SaveJson(agentListPath, list);
}
private static void SaveJson<T>(string path, T obj)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(obj, JsonOpts);
File.WriteAllText(path, json);
}
private static T? LoadJson<T>(string path)
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, JsonOpts);
}
private static string SanitizeName(string name)
{
var sanitized = name.Trim();
foreach (var c in Path.GetInvalidFileNameChars())
sanitized = sanitized.Replace(c, '_');
return sanitized.Replace(' ', '_');
}
}