FileRW: Sammel-Lesen (#663) und Hinweis auf den anderen Workspace (#662)

#663: Neue Aktion read_many liest mehrere Dateien in einem Aufruf - entweder
paths (Liste) oder path (Verzeichnis) mit pattern. Gedeckelt auf 50 Dateien und
200.000 Zeichen; was nicht mehr hineinpasst, wird namentlich genannt, damit der
Agent gezielt nachholen kann. Anlass: Der Tagesabschluss im Testbetrieb las 24
Stundendateien einzeln und starb zweimal an der Schrittgrenze - die soll
Endlosschleifen abfangen, nicht ehrliche Arbeit.

#662: Schlaegt read oder list fehl, weil der Pfad im anderen Workspace liegt,
sagt die Fehlermeldung das - aber nur, wenn der Agent diesen Workspace auch
benutzen darf. Guenstige Modelle vergassen workspace "shared" regelmaessig und
verbrauchten mit jedem Rateversuch einen Schritt.

Dateipruefung, Endungen und Workspace-Grenze gelten in read_many unveraendert;
eine verbotene Endung wird uebergangen statt den Lauf zu kippen.

Version 0.1.14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-21 15:53:08 +02:00
co-authored by Claude Opus 5
parent 996330a77e
commit 24e457b11f
4 changed files with 418 additions and 8 deletions
+199 -6
View File
@@ -27,7 +27,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
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, write, append, list, delete, copy, stock_add.\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" +
"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.";
@@ -38,8 +41,8 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
"properties": {
"action": {
"type": "string",
"enum": ["read", "write", "append", "list", "delete", "copy", "stock_add"],
"description": "Die auszuführende Aktion. 'stock_add' fügt einen strukturierten Datenpunkt zur Aktien-Wissensdatenbank hinzu."
"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."
},
"workspace": {
"type": "string",
@@ -54,6 +57,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
"type": "string",
"description": "Inhalt für write oder append Aktionen"
},
"paths": {
"type": "array",
"items": { "type": "string" },
"description": "Nur für read_many: die zu lesenden Dateien. Alternativ 'path' (Verzeichnis) mit 'pattern'."
},
"pattern": {
"type": "string",
"description": "Nur für read_many: Dateimuster im Verzeichnis 'path', z.B. 'berlin_2026-09-20_*.txt'. Ohne Muster werden alle Dateien des Verzeichnisses gelesen."
},
"destinationWorkspace": {
"type": "string",
"enum": ["personal", "shared"],
@@ -121,6 +133,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
return action switch
{
"read" => await HandleReadAsync(input, rootPath, workspace, context, ct),
"read_many" => await HandleReadManyAsync(input, rootPath, workspace, context, ct),
"write" => await HandleWriteAsync(input, rootPath, workspace, context, ct),
"append" => await HandleAppendAsync(input, rootPath, workspace, context, ct),
"list" => await HandleListAsync(input, rootPath, workspace, context, ct),
@@ -185,7 +198,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
return action switch
{
"read" or "list" or "copy" => true,
"read" or "read_many" or "list" or "copy" => true,
"write" or "append" or "stock_add" => level is "ReadWrite" or "Admin",
"delete" => level is "Admin",
_ => false
@@ -348,10 +361,54 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
return fullPath;
}
/// <summary>
/// #662: Gibt es den Pfad im jeweils anderen Workspace, sagt die Fehlermeldung das.
///
/// <para>Anlass aus dem Testbetrieb: Ein guenstiges Modell vergisst regelmaessig
/// <c>workspace: "shared"</c> und bekommt nur "nicht gefunden" zurueck. Es rät dann
/// weiter, und jeder Versuch kostet einen Schritt. Der Hinweis kommt nur, wenn der
/// Agent den anderen Workspace auch benutzen darf - sonst waere er eine Einladung
/// zu etwas, das ohnehin abgewiesen wird.</para>
/// </summary>
private string AndererWorkspaceHinweis(
string action, string workspace, string? relativePath, AgentToolContext context, bool verzeichnis)
{
var anderer = workspace == "shared" ? "personal" : "shared";
if (!IsActionAllowed(action, anderer, context))
return "";
var root = anderer == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(root))
return "";
try
{
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."
: "";
}
catch
{
return ""; // Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem.
}
}
private static string? RelativerPfad(JsonElement input)
=> input.ValueKind == JsonValueKind.Object && input.TryGetProperty("path", out var p) && p.ValueKind == JsonValueKind.String
? p.GetString()
: null;
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
{
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
if (!File.Exists(path)) return ToolResult.Fail($"Datei nicht gefunden im {workspace} Workspace: {Path.GetRelativePath(rootPath, path)}");
if (!File.Exists(path))
{
return ToolResult.Fail(
$"Datei nicht gefunden im {workspace} Workspace: {Path.GetRelativePath(rootPath, path)}."
+ AndererWorkspaceHinweis("read", workspace, RelativerPfad(input), context, verzeichnis: false));
}
var fileLock = GetFileLock(path);
await fileLock.WaitAsync(ct);
@@ -367,6 +424,137 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
}
/// <summary>Hoechstzahl Dateien je <c>read_many</c>.</summary>
public const int MaxDateienJeAufruf = 50;
/// <summary>Hoechstzahl Zeichen je <c>read_many</c> - danach werden nur noch Namen genannt.</summary>
public const int MaxZeichenJeAufruf = 200_000;
/// <summary>
/// #663: Mehrere Dateien in einem Aufruf lesen.
///
/// <para>Anlass: Der Tagesabschluss im Testbetrieb muss 24 Stundendateien zu einem
/// Tagesarchiv zusammenfassen. Einzeln gelesen sind das 24 Schritte, und der Lauf
/// stirbt an der Schrittgrenze, bevor er fertig ist. Die Grenze soll Endlosschleifen
/// abfangen, nicht ehrliche Arbeit - also liest das Werkzeug jetzt auf einmal.</para>
///
/// <para>Entweder <c>paths</c> (ausdrueckliche Liste) oder <c>path</c> als Verzeichnis
/// mit optionalem <c>pattern</c>. Gedeckelt auf <see cref="MaxDateienJeAufruf"/> Dateien
/// und <see cref="MaxZeichenJeAufruf"/> Zeichen; was nicht mehr hineinpasst, wird am
/// Ende namentlich genannt, damit der Agent den Rest gezielt nachholen kann.</para>
/// </summary>
private async Task<ToolResult> HandleReadManyAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
{
var (dateien, fehler) = SammleDateien(input, rootPath, workspace, context);
if (fehler is not null)
return ToolResult.Fail(fehler);
if (dateien.Count == 0)
{
return ToolResult.Fail(
$"Keine passende Datei im {workspace} Workspace gefunden."
+ AndererWorkspaceHinweis("read_many", workspace, RelativerPfad(input), context, verzeichnis: true));
}
var uebersprungen = new List<string>();
var fehlend = new List<string>();
var text = new StringBuilder();
var gelesen = 0;
foreach (var pfad in dateien.Take(MaxDateienJeAufruf))
{
ct.ThrowIfCancellationRequested();
var relativ = Path.GetRelativePath(rootPath, pfad).Replace('\\', '/');
if (!File.Exists(pfad)) { fehlend.Add(relativ); continue; }
if (text.Length >= MaxZeichenJeAufruf) { uebersprungen.Add(relativ); continue; }
var sperre = GetFileLock(pfad);
await sperre.WaitAsync(ct);
string inhalt;
try { inhalt = await File.ReadAllTextAsync(pfad, ct); }
finally { sperre.Release(); }
text.Append($"\n--- {workspace}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
gelesen++;
}
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.");
if (fehlend.Count > 0)
kopf.Append($"\nNicht gefunden: {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 ? " …" : "")}");
return ToolResult.Ok(kopf.Append('\n').Append(text).ToString());
}
/// <summary>
/// Die zu lesenden Dateien: entweder aus <c>paths</c> oder aus Verzeichnis und Muster.
/// Jeder Pfad geht durch dieselbe Pruefung wie ein einzelnes <c>read</c>.
/// </summary>
private (List<string> Dateien, string? Fehler) SammleDateien(
JsonElement input, string rootPath, string workspace, AgentToolContext context)
{
try
{
if (input.TryGetProperty("paths", out var liste) && liste.ValueKind == JsonValueKind.Array)
{
var dateien = new List<string>();
foreach (var eintrag in liste.EnumerateArray())
{
if (eintrag.ValueKind != JsonValueKind.String) continue;
var relativ = eintrag.GetString();
if (string.IsNullOrWhiteSpace(relativ)) continue;
dateien.Add(PruefeLesepfad(relativ, rootPath, workspace, context));
}
return (dateien, dateien.Count == 0 ? "'paths' enthaelt keinen Pfad." : null);
}
var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), workspace);
if (!Directory.Exists(verzeichnis))
return ([], null); // Die Meldung samt Workspace-Hinweis macht der Aufrufer.
var muster = input.TryGetProperty("pattern", out var m) && m.ValueKind == JsonValueKind.String
? m.GetString()
: null;
var gefunden = Directory.GetFiles(verzeichnis, string.IsNullOrWhiteSpace(muster) ? "*" : muster)
.OrderBy(f => f, StringComparer.Ordinal)
.ToList();
// Dieselbe Endungspruefung wie beim einzelnen Lesen - was dort verboten ist,
// kommt auch hier nicht durch, nur ohne den Lauf abzubrechen.
return (gefunden.Where(f => IstLesbar(f, workspace, context)).ToList(), null);
}
catch (UnauthorizedAccessException ex)
{
return ([], $"Zugriff verweigert: {ex.Message}");
}
}
private string PruefeLesepfad(string relativePath, string rootPath, string workspace, AgentToolContext context)
{
var json = JsonSerializer.SerializeToElement(new { action = "read", path = relativePath });
return GetAndValidatePath(json, rootPath, workspace, true, context);
}
private bool IstLesbar(string vollerPfad, string workspace, AgentToolContext context)
{
try
{
var json = JsonSerializer.SerializeToElement(new { action = "read", path = Path.GetFileName(vollerPfad) });
GetAndValidatePath(json, Path.GetDirectoryName(vollerPfad)!, workspace, true, context);
return true;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
private async Task<ToolResult> HandleWriteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
{
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
@@ -431,7 +619,12 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
{
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
if (!Directory.Exists(path)) return ToolResult.Fail($"Verzeichnis nicht gefunden im {workspace} Workspace.");
if (!Directory.Exists(path))
{
return ToolResult.Fail(
$"Verzeichnis nicht gefunden im {workspace} Workspace: {Path.GetRelativePath(rootPath, path)}."
+ AndererWorkspaceHinweis("list", workspace, RelativerPfad(input), context, verzeichnis: true));
}
var relativeDirPath = Path.GetRelativePath(rootPath, path);
var entries = Directory.GetFileSystemEntries(path)