FileRW list sortiert und gedeckelt (#673), Taskboard task_find (#674)

Die Betriebskontrolle im Testbetrieb meldete Stundendateien als fehlend, die
es gab: 105 Eintraege in der Reihenfolge des Dateisystems, vierzeilig als
eingeruecktes JSON. list liefert jetzt die 50 zuletzt geaenderten Eintraege,
neueste zuerst, eine Zeile je Eintrag, mit Gesamtzahl im Kopf; pattern, sort,
order und limit steuern das.

Dabei aufgefallen (#675): Directory.GetFiles wertet Trennzeichen im Muster
aus - pattern "../*" fuehrte an der Pfadpruefung vorbei aus dem Verzeichnis
heraus, in list wie in read_many. Muster mit '/', '\' oder '..' werden jetzt
abgewiesen.

Und derselbe Befund ist keine zweite Aufgabe (#674): task_find beantwortet
die Frage vor dem Anlegen, task_create legt bei gleichbedeutendem Titel einer
offenen Aufgabe keine zweite an, sondern vermerkt das erneute Auftreten an
der bestehenden. force=true geht trotzdem, Erledigtes blockiert nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-21 21:19:19 +02:00
co-authored by Claude Opus 5
parent 24e457b11f
commit 060dc6a6ad
10 changed files with 815 additions and 19 deletions
@@ -29,7 +29,11 @@ public sealed class TaskboardTool : IAgentTool
Termine. Eine Aufgabe an einen anderen Agenten legst du mit assignee='@<agentId>'
an — sie läuft, wenn sie fällig ist, ohne dass du wartest.
Aktionen: task_create, task_list, task_update, task_comment
Aktionen: task_create, task_find, task_list, task_update, task_comment
Bevor du etwas meldest: task_find mit dem geplanten Titel. Gibt es die Aufgabe
schon, hänge deine Beobachtung mit task_comment an, statt sie erneut anzulegen —
sonst weiß niemand, ob ein Befund neu ist oder seit Tagen unbeantwortet liegt.
Assignee:
- '@new' → frischer Lauf ohne Vorgeschichte
@@ -48,8 +52,8 @@ public sealed class TaskboardTool : IAgentTool
"properties": {
"action": {
"type": "string",
"enum": ["task_create", "task_list", "task_update", "task_comment"],
"description": "Die auszuführende Aktion."
"enum": ["task_create", "task_find", "task_list", "task_update", "task_comment"],
"description": "Die auszuführende Aktion. 'task_find' sucht offene Aufgaben mit ähnlichem Titel — immer vor task_create."
},
"id": {
"type": "string",
@@ -57,7 +61,11 @@ public sealed class TaskboardTool : IAgentTool
},
"title": {
"type": "string",
"description": "Titel der Aufgabe. Pflicht bei task_create."
"description": "Titel der Aufgabe. Pflicht bei task_create und task_find."
},
"force": {
"type": "boolean",
"description": "task_create: trotz gleichlautender offener Aufgabe wirklich eine zweite anlegen. Nur, wenn es ein anderer Sachverhalt ist."
},
"body": {
"type": "string",
@@ -147,7 +155,8 @@ public sealed class TaskboardTool : IAgentTool
{
return action switch
{
"task_create" => await CreateAsync(input, board, ct),
"task_create" => await CreateAsync(input, board, context.AgentId, ct),
"task_find" => await FindAsync(input, board, ct),
"task_list" => await ListAsync(input, board, ct),
"task_update" => await UpdateAsync(input, board, ct),
"task_comment" => await CommentAsync(input, board, context.AgentId, ct),
@@ -163,8 +172,50 @@ public sealed class TaskboardTool : IAgentTool
// ─── Aktionen ───
private static async Task<ToolResult> CreateAsync(
/// <summary>
/// #674: Offene Aufgaben mit ähnlichem Titel — die Frage vor dem Anlegen.
///
/// <para>Die Freitextsuche in task_list beantwortet sie nicht: sie findet nur, was
/// wörtlich übereinstimmt, und liefert eine Liste, die der Agent selbst deuten muss.
/// Hier steht die Antwort im ersten Satz.</para>
/// </summary>
private static async Task<ToolResult> FindAsync(
JsonElement input, TaskboardService board, CancellationToken ct)
{
var title = GetString(input, "title") ?? GetString(input, "search");
if (string.IsNullOrWhiteSpace(title))
return ToolResult.Fail("'title' ist erforderlich — wonach soll gesucht werden?");
var treffer = await SucheAehnlicheAsync(board, title, null, ct);
if (treffer.Count == 0)
return ToolResult.Ok($"Keine offene Aufgabe ähnelt \"{title.Trim()}\". Du kannst sie mit task_create anlegen.");
var sb = new StringBuilder();
sb.AppendLine($"{treffer.Count} offene Aufgabe(n) ähneln \"{title.Trim()}\":");
sb.AppendLine();
foreach (var (task, match) in treffer.Take(10))
{
sb.AppendLine($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}");
sb.AppendLine($" {(match == TaskTitleMatch.Duplicate ? "derselbe Sachverhalt" : "verwandt")}" +
$" | Assignee: {task.Assignee} | Priorität: {task.Priority}" +
$" | offen seit {task.CreatedAt:dd.MM. HH:mm}");
}
sb.AppendLine();
sb.Append("Hänge deine Beobachtung mit task_comment an die bestehende Aufgabe an, statt eine zweite anzulegen.");
return ToolResult.Ok(sb.ToString());
}
/// <summary>Offene Aufgaben mit verwandtem Titel, engste zuerst.</summary>
private static async Task<List<(TaskItem Task, TaskTitleMatch Match)>> SucheAehnlicheAsync(
TaskboardService board, string title, string? exceptId, CancellationToken ct)
{
var alle = await board.ListAsync(new TaskQuery { Limit = 500 }, ct);
return TaskSimilarity.FindOpen(alle, title, exceptId);
}
private static async Task<ToolResult> CreateAsync(
JsonElement input, TaskboardService board, string author, CancellationToken ct)
{
var title = GetString(input, "title");
if (string.IsNullOrWhiteSpace(title))
@@ -173,6 +224,27 @@ public sealed class TaskboardTool : IAgentTool
if (!TryReadWhen(input, out var when, out var whenError))
return ToolResult.Fail(whenError!);
// #674: Derselbe Befund zum zweiten Mal ist keine zweite Aufgabe. Er wird an der
// bestehenden vermerkt — damit man später sieht, wie oft und wie lange schon.
if (GetBool(input, "force") != true)
{
var dublette = (await SucheAehnlicheAsync(board, title, null, ct))
.FirstOrDefault(t => t.Match == TaskTitleMatch.Duplicate).Task;
if (dublette is not null)
{
await board.AddCommentAsync(
dublette.Id, author,
$"Erneut beobachtet — {author} wollte dieselbe Aufgabe anlegen: \"{title.Trim()}\"",
ct);
return ToolResult.Fail(
$"Nicht angelegt: {dublette.Id} \"{dublette.Title}\" ist seit {dublette.CreatedAt:dd.MM. HH:mm} " +
$"offen und meint dasselbe. Dein erneutes Auftreten wurde dort vermerkt.\n" +
$"Ergänze sie mit task_comment, oder lege bei wirklich anderem Sachverhalt mit force=true an.");
}
}
var definition = new TaskItem
{
Title = title.Trim(),