#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:
@@ -18,7 +18,7 @@
|
|||||||
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
||||||
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
|
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
|
||||||
-->
|
-->
|
||||||
<Version>0.1.13</Version>
|
<Version>0.1.14</Version>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
|
Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
|
||||||
|
|||||||
@@ -256,7 +256,17 @@ Der `AgentEngine` nutzt dann die `ToolRegistry`, um für jeden Agent nur die erl
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Name | `FileRW` |
|
| Name | `FileRW` |
|
||||||
| NuGet | (keine) |
|
| NuGet | (keine) |
|
||||||
| Aktionen | `read`, `write`, `append`, `list`, `delete` |
|
| Aktionen | `read`, `read_many`, `write`, `append`, `list`, `delete`, `copy`, `stock_add` |
|
||||||
|
|
||||||
|
`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, steht namentlich am Ende der Antwort. Der Grund steht in #663: Ein Lauf, der
|
||||||
|
24 Dateien einzeln liest, verbraucht 24 Schritte und stirbt an der Schrittgrenze – die soll
|
||||||
|
Endlosschleifen abfangen, nicht ehrliche Arbeit.
|
||||||
|
|
||||||
|
Schlägt ein Zugriff fehl, weil der Pfad im *anderen* Workspace liegt, sagt die
|
||||||
|
Fehlermeldung das (#662) – aber nur, wenn der Agent den anderen Workspace auch benutzen
|
||||||
|
darf.
|
||||||
|
|
||||||
**Config-Felder:**
|
**Config-Felder:**
|
||||||
- `rootPath` – Basisverzeichnis (Agent ist darin eingesperrt)
|
- `rootPath` – Basisverzeichnis (Agent ist darin eingesperrt)
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
public string Description => "Dateiverwaltung in zwei getrennten Workspace-Verzeichnissen:\n" +
|
public string Description => "Dateiverwaltung in zwei getrennten Workspace-Verzeichnissen:\n" +
|
||||||
"- workspace='personal': Dein PRIVATER Ordner — nur du hast Zugriff.\n" +
|
"- workspace='personal': Dein PRIVATER Ordner — nur du hast Zugriff.\n" +
|
||||||
"- workspace='shared': GETEILTER Ordner — alle Agenten im Team können darauf zugreifen.\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" +
|
"Bei copy kann zwischen Workspaces kopiert werden.\n" +
|
||||||
"'stock_add' fügt einen neuen Datenpunkt zur Aktien-Wissensdatenbank hinzu (shared:stocks/{ticker}/). " +
|
"'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.";
|
"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": {
|
"properties": {
|
||||||
"action": {
|
"action": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["read", "write", "append", "list", "delete", "copy", "stock_add"],
|
"enum": ["read", "read_many", "write", "append", "list", "delete", "copy", "stock_add"],
|
||||||
"description": "Die auszuführende Aktion. 'stock_add' fügt einen strukturierten Datenpunkt zur Aktien-Wissensdatenbank hinzu."
|
"description": "Die auszuführende Aktion. 'read_many' liest mehrere Dateien auf einmal. 'stock_add' fügt einen strukturierten Datenpunkt zur Aktien-Wissensdatenbank hinzu."
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -54,6 +57,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Inhalt für write oder append Aktionen"
|
"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": {
|
"destinationWorkspace": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["personal", "shared"],
|
"enum": ["personal", "shared"],
|
||||||
@@ -121,6 +133,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
return action switch
|
return action switch
|
||||||
{
|
{
|
||||||
"read" => await HandleReadAsync(input, rootPath, workspace, context, ct),
|
"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),
|
"write" => await HandleWriteAsync(input, rootPath, workspace, context, ct),
|
||||||
"append" => await HandleAppendAsync(input, rootPath, workspace, context, ct),
|
"append" => await HandleAppendAsync(input, rootPath, workspace, context, ct),
|
||||||
"list" => await HandleListAsync(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
|
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",
|
"write" or "append" or "stock_add" => level is "ReadWrite" or "Admin",
|
||||||
"delete" => level is "Admin",
|
"delete" => level is "Admin",
|
||||||
_ => false
|
_ => false
|
||||||
@@ -348,10 +361,54 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
return fullPath;
|
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)
|
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
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);
|
var fileLock = GetFileLock(path);
|
||||||
await fileLock.WaitAsync(ct);
|
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)
|
private async Task<ToolResult> HandleWriteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
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)
|
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
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 relativeDirPath = Path.GetRelativePath(rootPath, path);
|
||||||
var entries = Directory.GetFileSystemEntries(path)
|
var entries = Directory.GetFileSystemEntries(path)
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClawdDotNet.Core.State;
|
||||||
|
using ClawdDotNet.Core.Tools;
|
||||||
|
using ClawdDotNet.Tools.FileRW;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Tools.Tests.FileRW;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sammel-Lesen (#663) und der Hinweis auf den anderen Workspace (#662).
|
||||||
|
///
|
||||||
|
/// Anlass aus dem Testbetrieb: Der Tagesabschluss las 24 Stundendateien einzeln und starb
|
||||||
|
/// an der Schrittgrenze, und derselbe Agent rief Dateien wiederholt im falschen Workspace
|
||||||
|
/// ab, weil die Fehlermeldung nicht sagte, wo sie liegen.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SammelLesenTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _basis;
|
||||||
|
private readonly string _personal;
|
||||||
|
private readonly string _shared;
|
||||||
|
private readonly FileRWTool _tool = new();
|
||||||
|
|
||||||
|
public SammelLesenTests()
|
||||||
|
{
|
||||||
|
_basis = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
_personal = Path.Combine(_basis, "Workspace");
|
||||||
|
_shared = Path.Combine(_basis, "SharedWorkspace");
|
||||||
|
Directory.CreateDirectory(_personal);
|
||||||
|
Directory.CreateDirectory(Path.Combine(_shared, "wetter", "stunden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_basis, recursive: true); }
|
||||||
|
catch { /* Aufräumen ist Nebensache */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentToolContext Context(string sharedLevel = "ReadWrite") => new(
|
||||||
|
"hausmeister", "inst",
|
||||||
|
new Dictionary<string, object?> { ["sharedAccessLevel"] = sharedLevel },
|
||||||
|
new NullStateStore(),
|
||||||
|
NullLogger.Instance,
|
||||||
|
CancellationToken.None,
|
||||||
|
WorkspacePath: _personal,
|
||||||
|
SharedWorkspacePath: _shared);
|
||||||
|
|
||||||
|
private Task<ToolResult> Run(string json, string sharedLevel = "ReadWrite")
|
||||||
|
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(sharedLevel), CancellationToken.None);
|
||||||
|
|
||||||
|
private void Stundendateien(int anzahl, string tag = "2026-09-20")
|
||||||
|
{
|
||||||
|
for (var stunde = 0; stunde < anzahl; stunde++)
|
||||||
|
{
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(_shared, "wetter", "stunden", $"berlin_{tag}_{stunde:00}-00.txt"),
|
||||||
|
$"{tag} {stunde:00}:00 Temperatur {10 + stunde} C");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ #663: ein Aufruf statt 24 ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ein_Verzeichnis_mit_Muster_kommt_in_einem_Aufruf()
|
||||||
|
{
|
||||||
|
Stundendateien(24);
|
||||||
|
Stundendateien(3, "2026-09-19");
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden","pattern":"berlin_2026-09-20_*.txt"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||||
|
result.Content.ShouldContain("24 von 24 Datei(en) gelesen");
|
||||||
|
result.Content.ShouldContain("Temperatur 10 C");
|
||||||
|
result.Content.ShouldContain("Temperatur 33 C");
|
||||||
|
result.Content.ShouldNotContain("2026-09-19", Case.Sensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Die_Dateien_kommen_benannt_und_in_fester_Reihenfolge()
|
||||||
|
{
|
||||||
|
Stundendateien(3);
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
|
||||||
|
|
||||||
|
var reihenfolge = new[] { "00-00.txt", "01-00.txt", "02-00.txt" }
|
||||||
|
.Select(n => result.Content.IndexOf(n, StringComparison.Ordinal)).ToList();
|
||||||
|
reihenfolge.ShouldAllBe(i => i > 0);
|
||||||
|
reihenfolge.ShouldBe(reihenfolge.OrderBy(i => i).ToList(), "sortiert, damit der Agent den Tag zusammensetzen kann");
|
||||||
|
result.Content.ShouldContain("--- shared:/wetter/stunden/berlin_2026-09-20_01-00.txt ---");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Eine_ausdrueckliche_Liste_wird_gelesen_und_Fehlendes_benannt()
|
||||||
|
{
|
||||||
|
Stundendateien(2);
|
||||||
|
|
||||||
|
var result = await Run("""
|
||||||
|
{"action":"read_many","workspace":"shared","paths":[
|
||||||
|
"wetter/stunden/berlin_2026-09-20_00-00.txt",
|
||||||
|
"wetter/stunden/berlin_2026-09-20_09-00.txt"]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||||
|
result.Content.ShouldContain("1 von 2 Datei(en) gelesen");
|
||||||
|
result.Content.ShouldContain("Nicht gefunden: wetter/stunden/berlin_2026-09-20_09-00.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Zu_viele_Dateien_werden_gedeckelt_und_der_Rest_genannt()
|
||||||
|
{
|
||||||
|
Stundendateien(60);
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||||
|
result.Content.ShouldContain($"{FileRWTool.MaxDateienJeAufruf} von 60 Datei(en) gelesen");
|
||||||
|
result.Content.ShouldContain("Nicht mehr gelesen");
|
||||||
|
result.Content.ShouldContain("hole sie mit einem weiteren Aufruf");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ein_leeres_Verzeichnis_meldet_sich_als_Fehler()
|
||||||
|
{
|
||||||
|
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Keine passende Datei");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ohne_Leserecht_am_gemeinsamen_Bereich_kommt_nichts_heraus()
|
||||||
|
{
|
||||||
|
Stundendateien(2);
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""", sharedLevel: "Denied");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Zugriff verweigert");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Der_Ausbruch_aus_dem_Workspace_wird_auch_hier_abgewiesen()
|
||||||
|
{
|
||||||
|
var result = await Run("""{"action":"read_many","workspace":"shared","paths":["../../geheim.txt"]}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Zugriff verweigert");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Eine_verbotene_Dateiendung_wird_uebergangen_statt_den_Lauf_zu_kippen()
|
||||||
|
{
|
||||||
|
Stundendateien(2);
|
||||||
|
File.WriteAllText(Path.Combine(_shared, "wetter", "stunden", "skript.exe"), "MZ");
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"read_many","workspace":"shared","path":"wetter/stunden"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||||
|
result.Content.ShouldContain("2 von 2 Datei(en) gelesen");
|
||||||
|
result.Content.ShouldNotContain("skript.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════ #662: Hinweis auf den anderen Workspace ═══════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Liegt_die_Datei_im_anderen_Workspace_sagt_die_Meldung_das()
|
||||||
|
{
|
||||||
|
Stundendateien(1);
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"read","path":"wetter/stunden/berlin_2026-09-20_00-00.txt"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("existiert im shared Workspace");
|
||||||
|
result.ErrorMessage.ShouldContain("workspace: \"shared\"", Case.Sensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Auch_ein_Verzeichnis_bekommt_den_Hinweis()
|
||||||
|
{
|
||||||
|
var result = await Run("""{"action":"list","path":"wetter/stunden"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("existiert im shared Workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ohne_Recht_am_anderen_Workspace_gibt_es_keinen_Hinweis()
|
||||||
|
{
|
||||||
|
// Ein Hinweis auf etwas, das der Agent ohnehin nicht darf, schickt ihn ins Leere.
|
||||||
|
Stundendateien(1);
|
||||||
|
|
||||||
|
var result = await Run("""{"action":"read","path":"wetter/stunden/berlin_2026-09-20_00-00.txt"}""", sharedLevel: "Denied");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.ErrorMessage.ShouldNotBeNull().ShouldNotContain("existiert im");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Gibt_es_den_Pfad_nirgends_bleibt_die_Meldung_schlicht()
|
||||||
|
{
|
||||||
|
var result = await Run("""{"action":"read","workspace":"shared","path":"wetter/stunden/fehlt.txt"}""");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.ErrorMessage.ShouldNotBeNull().ShouldContain("Datei nicht gefunden");
|
||||||
|
result.ErrorMessage.ShouldNotContain("existiert im");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user