i18n: Englisch als Hauptsprache, Sprachpakete fuer Desktop und WebUI (#661)

Umbau durch Gemini Flash, Durchsicht und Nacharbeit durch Claude.

Umfang:
- Desktop: LocalizationManager (JSON-Pakete, Rueckfall auf Englisch,
  Umschalten zur Laufzeit), Localize-Markup-Erweiterung, Sprachwahl in den
  Einstellungen (AppSettings.Language, Vorgabe "en"); 317 Schluessel je Sprache
- WebUI: alle Ansichten auf t()/data-i18n, 656 Schluessel je Sprache
- Agenten-Werkzeuge: Beschreibungen, Schemata und Meldungen auf Englisch
- Tests an die englischen Meldungen angepasst, keine Pruefung abgeschwaecht

Nacharbeit bei der Durchsicht:
- ~25 Kommentarbloecke mit Begruendungen waren ersatzlos entfallen, darunter
  sicherheitsrelevante (NTFS-Datenstrom beim shared:-Praefix, Bremse gegen
  Agentenschleifen, doppelter POST beim Anlegen einer Instanz). Wieder
  eingesetzt: auf Englisch, wo die Datei umgestellt ist, sonst im Wortlaut.
- Drei WebUI-Schluessel fehlten (logs.paused, instances.instanceOfflineError,
  instances.noInstanceActiveError). Weil t() bei fehlendem Schluessel den
  Schluessel selbst liefert, griff der "|| err.message"-Rueckfall nie — die
  Oberflaeche haette den Schluesselnamen angezeigt.

Geprueft: Schluesselgleichheit en/de, Platzhalter je Schluessel, jeder im
Code benutzte Schluessel vorhanden, node --check fuer alle Module,
1.220 Tests gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 09:43:51 +02:00
co-authored by Claude Opus 5.5
parent 69718122f3
commit ad71682c2d
97 changed files with 4103 additions and 1928 deletions
+93 -95
View File
@@ -24,19 +24,19 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
public string Name => "FileRW";
public string Description => "Dateiverwaltung in zwei getrennten Workspace-Verzeichnissen:\n" +
"- workspace='personal': Dein PRIVATER Ordner — nur du hast Zugriff.\n" +
"- workspace='shared': GETEILTER Ordner — alle Agenten im Team können darauf zugreifen.\n" +
"Aktionen: read, read_many, write, append, list, delete, copy, stock_add.\n" +
"'read_many' liest mehrere Dateien in EINEM Aufruf - entweder 'paths' (Liste) oder 'path' " +
"(Verzeichnis) mit 'pattern' (z.B. \"berlin_2026-09-20_*.txt\"). Nimm das, statt viele " +
"Dateien einzeln zu lesen: jeder Einzelaufruf kostet einen Schritt.\n" +
"'list' zeigt standardmäßig die 50 zuletzt geänderten Einträge, neueste zuerst; " +
"'pattern', 'sort', 'order' und 'limit' steuern das. Frage nach einer bestimmten Datei " +
"lieber mit 'pattern', statt eine lange Liste zu durchsuchen.\n" +
"Bei copy kann zwischen Workspaces kopiert werden.\n" +
"'stock_add' fügt einen neuen Datenpunkt zur Aktien-Wissensdatenbank hinzu (shared:stocks/{ticker}/). " +
"Geschützte Pfade (protectedPaths) erlauben nur das Erstellen neuer Dateien — kein Überschreiben oder Löschen.";
public string Description => "File management across two separate workspace directories:\n" +
"- workspace='personal': Your PRIVATE folder — only you have access.\n" +
"- workspace='shared': SHARED folder — all agents on the team can access it.\n" +
"Actions: read, read_many, write, append, list, delete, copy, stock_add.\n" +
"'read_many' reads multiple files in ONE call - either 'paths' (list) or 'path' " +
"(directory) with 'pattern' (e.g. \"berlin_2026-09-20_*.txt\"). Use this instead of reading " +
"many files individually: each individual call costs a step.\n" +
"'list' by default shows the 50 most recently modified entries, newest first; " +
"'pattern', 'sort', 'order' and 'limit' control this. It is better to query a specific file " +
"using 'pattern' rather than searching through a long list.\n" +
"For copy, files can be copied between workspaces.\n" +
"'stock_add' adds a new data point to the stock knowledge base (shared:stocks/{ticker}/). " +
"Protected paths (protectedPaths) only allow creating new files — no overwriting or deleting.";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
@@ -45,73 +45,73 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
"action": {
"type": "string",
"enum": ["read", "read_many", "write", "append", "list", "delete", "copy", "stock_add"],
"description": "Die auszuführende Aktion. 'read_many' liest mehrere Dateien auf einmal. 'stock_add' fügt einen strukturierten Datenpunkt zur Aktien-Wissensdatenbank hinzu."
"description": "The action to perform. 'read_many' reads multiple files at once. 'stock_add' adds a structured data point to the stock knowledge base."
},
"workspace": {
"type": "string",
"enum": ["personal", "shared"],
"description": "In welchem Workspace die Aktion ausgeführt werden soll (Quell-Workspace bei copy). Standard ist 'personal'."
"description": "The workspace in which to execute the action (source workspace for copy). Default is 'personal'."
},
"path": {
"type": "string",
"description": "Relativer Pfad zur Datei oder zum Verzeichnis"
"description": "Relative path to the file or directory"
},
"content": {
"type": "string",
"description": "Inhalt für write oder append Aktionen"
"description": "Content for write or append actions"
},
"paths": {
"type": "array",
"items": { "type": "string" },
"description": "Nur für read_many: die zu lesenden Dateien. Alternativ 'path' (Verzeichnis) mit 'pattern'."
"description": "Only for read_many: files to read. Alternatively 'path' (directory) with 'pattern'."
},
"pattern": {
"type": "string",
"description": "Für read_many und list: Dateimuster im Verzeichnis 'path', z.B. 'berlin_2026-09-20_*.txt'. Ohne Muster gilt das ganze Verzeichnis."
"description": "For read_many and list: file pattern within directory 'path', e.g. 'berlin_2026-09-20_*.txt'. Without pattern, the entire directory is used."
},
"sort": {
"type": "string",
"enum": ["modified", "name"],
"description": "Nur für list: Sortierung. Standard 'modified' (neueste zuerst)."
"description": "Only for list: sorting. Default 'modified' (newest first)."
},
"order": {
"type": "string",
"enum": ["desc", "asc"],
"description": "Nur für list: Richtung. Standard absteigend bei 'modified', aufsteigend bei 'name'."
"description": "Only for list: sort direction. Default descending for 'modified', ascending for 'name'."
},
"limit": {
"type": "integer",
"description": "Nur für list: Höchstzahl der Einträge. Standard 50, Maximum 500. Der Kopf der Antwort nennt immer die Gesamtzahl."
"description": "Only for list: maximum number of entries. Default 50, maximum 500. The response header always mentions total count."
},
"destinationWorkspace": {
"type": "string",
"enum": ["personal", "shared"],
"description": "Ziel-Workspace für copy. Standard ist gleicher Workspace wie 'workspace'."
"description": "Destination workspace for copy. Default is the same workspace as 'workspace'."
},
"destinationPath": {
"type": "string",
"description": "Relativer Zielpfad für copy. Pflichtfeld bei copy."
"description": "Relative destination path for copy. Required for copy."
},
"ticker": {
"type": "string",
"description": "Aktien-Ticker für stock_add (z.B. 'NVDA', 'TSLA'). Wird automatisch zu Großbuchstaben."
"description": "Stock ticker for stock_add (e.g. 'NVDA', 'TSLA'). Automatically converted to uppercase."
},
"category": {
"type": "string",
"enum": ["news", "social", "analysis", "capitol", "price", "earnings", "filing", "sentiment", "other"],
"description": "Kategorie des Datenpunkts für stock_add."
"description": "Category of data point for stock_add."
},
"source": {
"type": "string",
"description": "Quelle des Datenpunkts für stock_add (z.B. 'reuters', 'x_unusual_whales', 'youtube', 'reddit')."
"description": "Source of data point for stock_add (e.g. 'reuters', 'x_unusual_whales', 'youtube', 'reddit')."
},
"title": {
"type": "string",
"description": "Kurze Überschrift/Zusammenfassung des Datenpunkts für stock_add."
"description": "Short headline/summary of data point for stock_add."
},
"data": {
"type": "object",
"description": "Strukturierte Daten des Datenpunkts für stock_add. Kann beliebige Felder enthalten."
"description": "Structured data of data point for stock_add. Can contain arbitrary fields."
}
},
"required": ["action"]
@@ -133,7 +133,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (string.IsNullOrWhiteSpace(rootPath))
{
var wsName = workspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace";
return ToolResult.Fail($"Konfigurationsfehler: '{wsName}' ist nicht definiert.");
return ToolResult.Fail($"Configuration error: '{wsName}' is not defined.");
}
// Sicherstellen, dass rootPath absolut ist
@@ -142,7 +142,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// Zugriffsprüfung
if (!IsActionAllowed(action, workspace, context))
{
return ToolResult.Fail($"Zugriff verweigert: Die Aktion '{action}' ist im Workspace '{workspace}' für diesen Agenten nicht erlaubt.");
return ToolResult.Fail($"Access denied: Action '{action}' is not allowed in workspace '{workspace}' for this agent.");
}
try
@@ -157,13 +157,13 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
"delete" => await HandleDeleteAsync(input, rootPath, workspace, context, ct),
"copy" => await HandleCopyAsync(input, rootPath, workspace, context, ct),
"stock_add" => await HandleStockAddAsync(input, context, ct),
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
_ => ToolResult.Fail($"Unknown action: {action}")
};
}
catch (Exception ex)
{
context.Logger.LogError(ex, "Fehler bei FileRW Aktion {Action}", action);
return ToolResult.Fail($"Fehler: {ex.Message}");
context.Logger.LogError(ex, "Error during FileRW action {Action}", action);
return ToolResult.Fail($"Error: {ex.Message}");
}
}
@@ -182,7 +182,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
? w.GetString() : null) ?? "personal";
if (!IsActionAllowed(action, workspace, context))
return $"Zugriff verweigert: Die Aktion '{action}' ist im Workspace '{workspace}' für diesen Agenten nicht erlaubt.";
return $"Access denied: Action '{action}' is not allowed in workspace '{workspace}' for this agent.";
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (action != "delete" || string.IsNullOrWhiteSpace(rootPath))
@@ -194,12 +194,12 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
if (workspace == "shared" && !IsAdminLevel(context) && IsPathProtected(path, rootPath, context))
return $"🛡️ Geschützter Bereich: '{Path.GetRelativePath(rootPath, path)}' darf nicht gelöscht werden.";
return $"🛡️ Protected area: '{Path.GetRelativePath(rootPath, path)}' cannot be deleted.";
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Dieselbe Pfadpruefung wirft bei der Ausfuehrung - dort wird daraus ein Fehler.
return $"Zugriff verweigert: {ex.Message}";
return $"Access denied: {ex.Message}";
}
return null;
@@ -293,13 +293,13 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var action = arguments.TryGetProperty("action", out var a) ? a.GetString() : null;
if (action is not ("write" or "append"))
return ProbeFinding.Unknown($"Für die Aktion '{action ?? "?"}' gibt es nichts nachzusehen.");
return ProbeFinding.Unknown($"Nothing to probe for action '{action ?? "?"}'.");
var workspace = (arguments.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(rootPath))
return ProbeFinding.Unknown("Workspace-Pfad ist nicht bekannt.");
return ProbeFinding.Unknown("Workspace path is unknown.");
var path = GetAndValidatePath(arguments, Path.GetFullPath(rootPath), workspace, true, context);
var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
@@ -307,7 +307,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (!File.Exists(path))
{
return new ProbeFinding(ProbeOutcome.NotStarted,
"Die Datei existiert nicht — geschrieben wurde nichts.");
"File does not exist — nothing was written.");
}
var vorhanden = await File.ReadAllTextAsync(path, ct);
@@ -316,17 +316,16 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
{
return vorhanden == erwartet
? new ProbeFinding(ProbeOutcome.Complete,
$"Der Inhalt entspricht genau dem beabsichtigten ({erwartet.Length} Zeichen).")
$"Content matches intended content exactly ({erwartet.Length} characters).")
: new ProbeFinding(ProbeOutcome.NotStarted,
"Die Datei trägt noch den alten Inhalt. Seit #553 wird atomar " +
"geschrieben — eine halbe Fassung kann es nicht geben.");
"File still has old content. Writes are atomic — partial versions cannot exist.");
}
// append
if (vorhanden.EndsWith(erwartet, StringComparison.Ordinal))
{
return new ProbeFinding(ProbeOutcome.Complete,
$"Der beabsichtigte Text steht vollständig am Ende ({erwartet.Length} Zeichen).");
$"Intended text is completely at the end ({erwartet.Length} characters).");
}
// Wie viel vom Erwarteten steht am Ende? Das ist die Rissstelle.
@@ -335,19 +334,18 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (vorhanden.EndsWith(erwartet[..laenge], StringComparison.Ordinal))
{
return new ProbeFinding(ProbeOutcome.Partial,
$"Angehängt wurden {laenge} von {erwartet.Length} Zeichen — " +
"der Aufruf riss mittendrin ab.");
$"Appended {laenge} of {erwartet.Length} characters — call was interrupted.");
}
}
return new ProbeFinding(ProbeOutcome.NotStarted,
"Vom beabsichtigten Text steht nichts am Ende der Datei.");
"None of the intended text is at the end of the file.");
}
catch (Exception ex)
{
// Nie werfen: Eine gescheiterte Nachschau ist keine Aussage über die Welt,
// sondern eine über die Nachschau.
return ProbeFinding.Unknown($"Nachschau nicht möglich: {ex.Message}");
return ProbeFinding.Unknown($"Probe not possible: {ex.Message}");
}
}
@@ -371,7 +369,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (!allowedExtensions.Contains(extension))
{
throw new UnauthorizedAccessException($"Dateiendung '{extension}' ist im Workspace '{workspace}' nicht erlaubt.");
throw new UnauthorizedAccessException($"File extension '{extension}' is not allowed in workspace '{workspace}'.");
}
}
@@ -403,7 +401,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var pfad = WorkspacePath.Resolve(Path.GetFullPath(root), relativePath, anderer);
var da = verzeichnis ? Directory.Exists(pfad) : File.Exists(pfad);
return da
? $" Der Pfad existiert im {anderer} Workspace — rufe mit workspace: \"{anderer}\" auf."
? $" The path exists in the {anderer} workspace — call with workspace: \"{anderer}\"."
: "";
}
catch
@@ -423,7 +421,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (!File.Exists(path))
{
return ToolResult.Fail(
$"Datei nicht gefunden im {workspace} Workspace: {Path.GetRelativePath(rootPath, path)}."
$"File not found in {workspace} workspace: {Path.GetRelativePath(rootPath, path)}."
+ AndererWorkspaceHinweis("read", workspace, RelativerPfad(input), context, verzeichnis: false));
}
@@ -469,7 +467,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (dateien.Count == 0)
{
return ToolResult.Fail(
$"Keine passende Datei im {workspace} Workspace gefunden."
$"No matching file found in {workspace} workspace."
+ AndererWorkspaceHinweis("read_many", workspace, RelativerPfad(input), context, verzeichnis: true));
}
@@ -498,12 +496,12 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
uebersprungen.AddRange(dateien.Skip(MaxDateienJeAufruf).Select(d => Path.GetRelativePath(rootPath, d).Replace('\\', '/')));
var kopf = new StringBuilder($"[{workspace} Workspace] {gelesen} von {dateien.Count} Datei(en) gelesen.");
var kopf = new StringBuilder($"[{workspace} Workspace] {gelesen} of {dateien.Count} file(s) read.");
if (fehlend.Count > 0)
kopf.Append($"\nNicht gefunden: {string.Join(", ", fehlend.Take(10))}{(fehlend.Count > 10 ? " …" : "")}");
kopf.Append($"\nNot found: {string.Join(", ", fehlend.Take(10))}{(fehlend.Count > 10 ? " …" : "")}");
if (uebersprungen.Count > 0)
kopf.Append($"\nNicht mehr gelesen (Grenze {MaxDateienJeAufruf} Dateien / {MaxZeichenJeAufruf} Zeichen), "
+ $"hole sie mit einem weiteren Aufruf: {string.Join(", ", uebersprungen.Take(10))}{(uebersprungen.Count > 10 ? " …" : "")}");
kopf.Append($"\nSkipped reading (limit {MaxDateienJeAufruf} files / {MaxZeichenJeAufruf} chars), "
+ $"fetch with an additional call: {string.Join(", ", uebersprungen.Take(10))}{(uebersprungen.Count > 10 ? " …" : "")}");
return ToolResult.Ok(kopf.Append('\n').Append(text).ToString());
}
@@ -527,7 +525,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (string.IsNullOrWhiteSpace(relativ)) continue;
dateien.Add(PruefeLesepfad(relativ, rootPath, workspace, context));
}
return (dateien, dateien.Count == 0 ? "'paths' enthaelt keinen Pfad." : null);
return (dateien, dateien.Count == 0 ? "'paths' contains no valid path." : null);
}
var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), workspace);
@@ -550,7 +548,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
catch (UnauthorizedAccessException ex)
{
return ([], $"Zugriff verweigert: {ex.Message}");
return ([], $"Access denied: {ex.Message}");
}
}
@@ -588,9 +586,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
{
var relPath = Path.GetRelativePath(rootPath, path);
return ToolResult.Fail(
$"🛡️ Geschützter Bereich: Die Datei '{relPath}' existiert bereits und darf nicht überschrieben werden. " +
$"Verwende 'append' um Daten hinzuzufügen, oder erstelle eine neue Datei mit anderem Namen. " +
$"Tipp: Nutze 'stock_add' für strukturierte Datenpunkte.");
$"🛡️ Protected area: File '{relPath}' already exists and cannot be overwritten. " +
$"Use 'append' to add data, or create a new file with a different name. " +
$"Tip: Use 'stock_add' for structured data points.");
}
var dir = Path.GetDirectoryName(path);
@@ -602,7 +600,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
// Agenten aber nicht.
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
return ToolResult.Ok($"[{workspace} Workspace] Datei erfolgreich geschrieben: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
return ToolResult.Ok($"[{workspace} Workspace] File successfully written: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
}
finally
{
@@ -627,7 +625,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
await writer.WriteAsync(content.AsMemory(), ct);
return ToolResult.Ok($"[{workspace} Workspace] Inhalt erfolgreich angehängt an: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
return ToolResult.Ok($"[{workspace} Workspace] Content successfully appended to: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
}
finally
{
@@ -660,7 +658,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (!Directory.Exists(path))
{
return ToolResult.Fail(
$"Verzeichnis nicht gefunden im {workspace} Workspace: {Path.GetRelativePath(rootPath, path)}."
$"Directory not found in {workspace} workspace: {Path.GetRelativePath(rootPath, path)}."
+ AndererWorkspaceHinweis("list", workspace, RelativerPfad(input), context, verzeichnis: true));
}
@@ -675,7 +673,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
catch (ArgumentException ex)
{
return ToolResult.Fail($"Ungueltiges Muster '{muster}': {ex.Message}");
return ToolResult.Fail($"Invalid pattern '{muster}': {ex.Message}");
}
var eintraege = pfade
@@ -689,7 +687,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var nachName = string.Equals(GetOptionalString(input, "sort"), "name", StringComparison.OrdinalIgnoreCase);
var absteigend = GetOptionalString(input, "order") is { } o
? string.Equals(o, "desc", StringComparison.OrdinalIgnoreCase)
: !nachName; // neueste zuerst, Namen aufsteigend
: !nachName; // newest first, names ascending
IEnumerable<Verzeichniseintrag> sortiert = nachName
? (absteigend
@@ -704,17 +702,17 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var relativeDirPath = Path.GetRelativePath(rootPath, path);
var ordnung = nachName
? (absteigend ? "Name absteigend" : "Name aufsteigend")
: (absteigend ? "neueste zuerst" : "aelteste zuerst");
? (absteigend ? "name descending" : "name ascending")
: (absteigend ? "newest first" : "oldest first");
var text = new StringBuilder();
text.Append($"[{workspace} Workspace] Verzeichnis: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
text.Append($" - {gezeigt.Count} von {eintraege.Count} Eintraegen, {ordnung}.");
text.Append($"[{workspace} Workspace] Directory: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
text.Append($" - {gezeigt.Count} of {eintraege.Count} entries, {ordnung}.");
if (!string.IsNullOrWhiteSpace(muster))
text.Append($" Muster: {muster}.");
text.Append($" Pattern: {muster}.");
if (eintraege.Count > gezeigt.Count)
text.Append($"\n{eintraege.Count - gezeigt.Count} weitere nicht gezeigt - grenze mit 'pattern' ein oder setze 'limit' (max {MaxEintraege}).");
text.Append("\nGeaendert Groesse Name\n");
text.Append($"\n{eintraege.Count - gezeigt.Count} more not shown - narrow down with 'pattern' or set 'limit' (max {MaxEintraege}).");
text.Append("\nModified Size Name\n");
foreach (var e in gezeigt)
{
@@ -737,8 +735,8 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (string.IsNullOrWhiteSpace(muster)) return null;
if (muster.Contains('/') || muster.Contains('\\') || muster.Contains(".."))
return $"Ungueltiges Muster '{muster}': ein Muster ist ein Dateiname mit Platzhaltern "
+ "(z.B. 'berlin_2026-09-21_*.txt'), kein Pfad. Das Verzeichnis gehoert in 'path'.";
return $"Invalid pattern '{muster}': a pattern is a file name with wildcards "
+ "(e.g. 'berlin_2026-09-21_*.txt'), not a path. The directory belongs in 'path'.";
return null;
}
@@ -758,8 +756,8 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
{
var relPath = Path.GetRelativePath(rootPath, path);
return ToolResult.Fail(
$"🛡️ Geschützter Bereich: '{relPath}' liegt in einem geschützten Verzeichnis und darf nicht gelöscht werden. " +
$"Daten in geschützten Bereichen sind append-only — sie können nur ergänzt, nicht entfernt werden.");
$"🛡️ Protected area: '{relPath}' is in a protected directory and cannot be deleted. " +
$"Data in protected areas is append-only — it can only be added, not removed.");
}
var fileLock = GetFileLock(path);
@@ -769,15 +767,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (File.Exists(path))
{
File.Delete(path);
return ToolResult.Ok($"[{workspace} Workspace] Datei gelöscht: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
return ToolResult.Ok($"[{workspace} Workspace] File deleted: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
}
else if (Directory.Exists(path))
{
Directory.Delete(path, true);
return ToolResult.Ok($"[{workspace} Workspace] Verzeichnis gelöscht: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
return ToolResult.Ok($"[{workspace} Workspace] Directory deleted: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
}
return ToolResult.Fail($"Datei oder Verzeichnis nicht gefunden im {workspace} Workspace.");
return ToolResult.Fail($"File or directory not found in {workspace} workspace.");
}
finally
{
@@ -793,16 +791,16 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var destRelPath = input.TryGetProperty("destinationPath", out var dp) ? dp.GetString() : null;
if (string.IsNullOrWhiteSpace(destRelPath))
return ToolResult.Fail("'destinationPath' ist für die copy-Aktion erforderlich.");
return ToolResult.Fail("'destinationPath' is required for copy action.");
var destRootPath = destWorkspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(destRootPath))
return ToolResult.Fail($"Konfigurationsfehler: '{(destWorkspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace")}' ist nicht definiert.");
return ToolResult.Fail($"Configuration error: '{(destWorkspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace")}' is not defined.");
destRootPath = Path.GetFullPath(destRootPath);
if (!IsActionAllowed("write", destWorkspace, context))
return ToolResult.Fail($"Zugriff verweigert: Schreiben im Workspace '{destWorkspace}' ist für diesen Agenten nicht erlaubt.");
return ToolResult.Fail($"Access denied: Writing to workspace '{destWorkspace}' is not allowed for this agent.");
var destInput = JsonDocument.Parse(JsonSerializer.Serialize(new { path = destRelPath })).RootElement;
var destPath = GetAndValidatePath(destInput, destRootPath, destWorkspace, false, context);
@@ -814,7 +812,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
{
var relPath = Path.GetRelativePath(destRootPath, destPath);
return ToolResult.Fail(
$"🛡️ Geschützter Bereich: Die Zieldatei '{relPath}' existiert bereits und darf nicht überschrieben werden.");
$"🛡️ Protected area: Destination file '{relPath}' already exists and cannot be overwritten.");
}
// Deadlock-sicheres Locking: Immer in alphabetischer Reihenfolge locken
@@ -838,7 +836,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}";
return ToolResult.Ok($"Datei kopiert: {srcLabel} → {dstLabel}");
return ToolResult.Ok($"File copied: {srcLabel} → {dstLabel}");
}
finally
{
@@ -856,10 +854,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}";
return ToolResult.Ok($"Verzeichnis kopiert: {srcLabel} → {dstLabel}");
return ToolResult.Ok($"Directory copied: {srcLabel} → {dstLabel}");
}
return ToolResult.Fail("Quelldatei oder -verzeichnis nicht gefunden.");
return ToolResult.Fail("Source file or directory not found.");
}
// ═══════════════════════════════════════════════════
@@ -878,7 +876,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// Pflichtfelder
var ticker = (input.TryGetProperty("ticker", out var t) ? t.GetString() : null)?.Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(ticker))
return ToolResult.Fail("'ticker' ist ein Pflichtfeld für stock_add (z.B. 'NVDA', 'TSLA').");
return ToolResult.Fail("'ticker' is required for stock_add (e.g. 'NVDA', 'TSLA').");
var category = (input.TryGetProperty("category", out var cat) ? cat.GetString() : null) ?? "other";
var source = (input.TryGetProperty("source", out var src) ? src.GetString() : null) ?? "unknown";
@@ -887,18 +885,18 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var data = input.TryGetProperty("data", out var d) ? d : (JsonElement?)null;
if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(content) && data is null)
return ToolResult.Fail("Mindestens 'title', 'content' oder 'data' muss angegeben werden.");
return ToolResult.Fail("At least 'title', 'content', or 'data' must be specified.");
// SharedWorkspace ist Pflicht für stocks
var sharedRoot = context.SharedWorkspacePath;
if (string.IsNullOrWhiteSpace(sharedRoot))
return ToolResult.Fail("SharedWorkspace ist nicht konfiguriert.");
return ToolResult.Fail("SharedWorkspace is not configured.");
sharedRoot = Path.GetFullPath(sharedRoot);
// Zugriffsprüfung
if (!IsActionAllowed("stock_add", "shared", context))
return ToolResult.Fail("Zugriff verweigert: Schreiben im SharedWorkspace nicht erlaubt.");
return ToolResult.Fail("Access denied: Writing to SharedWorkspace is not allowed.");
// Sanitize Ticker für Verzeichnisname
var safeTicker = SanitizeFileName(ticker);
@@ -1003,10 +1001,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var relPath = $"shared:/stocks/{safeTicker}/{fileName}";
return ToolResult.Ok(
$"✅ Datenpunkt hinzugefügt: {relPath}\n" +
$"Ticker: {ticker} | Kategorie: {category} | Quelle: {source}\n" +
$"Titel: {title}\n" +
$"Index: {indexCount} Einträge für {ticker}");
$"✅ Data point added: {relPath}\n" +
$"Ticker: {ticker} | Category: {category} | Source: {source}\n" +
$"Title: {title}\n" +
$"Index: {indexCount} entries for {ticker}");
}
private static string SanitizeFileName(string name)
@@ -34,7 +34,7 @@ public static class WorkspacePath
if (Path.IsPathRooted(value) || value.StartsWith("//"))
{
throw new UnauthorizedAccessException(
$"Zugriff verweigert: Absolute Pfade sind im {workspaceName} Workspace nicht erlaubt.");
$"Access denied: Absolute paths are not allowed in the {workspaceName} workspace.");
}
// Doppelpunkt und Backslash werden rundheraus abgewiesen.
@@ -54,8 +54,8 @@ public static class WorkspacePath
if (value.Contains(':') || value.Contains('\\'))
{
throw new UnauthorizedAccessException(
@"Zugriff verweigert: Der Pfad enthält ein unzulässiges Zeichen (':' oder '\'). " +
"Abschnitte werden mit '/' getrennt.");
@"Access denied: Path contains an invalid character (':' or '\'). " +
"Sections are separated with '/'.");
}
var normalizedRoot = NormalizeDirectory(root);
@@ -64,7 +64,7 @@ public static class WorkspacePath
if (!IsInside(fullPath, normalizedRoot))
{
throw new UnauthorizedAccessException(
$"Zugriff verweigert: Der Pfad liegt außerhalb des {workspaceName} Workspaces.");
$"Access denied: Path is outside the {workspaceName} workspace.");
}
return fullPath;