using System.Text; using System.Text.Json; using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Tasks; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Tools.Taskboard; /// /// Das Taskboard — Aufgaben statt Delay-Schleifen (A1). /// /// Aufgaben sind Markdown-Dateien mit Frontmatter im geteilten Arbeitsbereich; dieses /// Tool legt sie an, ändert und listet sie. Agent-zu-Agent-Delegation läuft hierüber /// (eine Aufgabe mit assignee: @<agent>) statt über rekursive Nachrichten — /// das ist strukturell zyklenfrei. Wann eine fällige Aufgabe tatsächlich läuft, /// entscheidet der Scanner, nicht dieses Tool. /// public sealed class TaskboardTool : IAgentTool { private const int MaxResultChars = 8_000; public string Name => "Taskboard"; public string Description => """ The taskboard — persists across runs and restarts. If your instance has units, you see your unit's board; a lead also sees the boards of the units below. Use it to track and distribute work instead of keeping it in context: own open items, assignments to other agents, recurring schedules. To assign a task to another agent, create it with assignee='@' — it runs when due, without you waiting. Actions: task_create, task_find, task_list, task_update, task_comment Before reporting something: run task_find with the planned title. If the task already exists, append your observation with task_comment instead of recreating it — otherwise nobody knows whether a finding is new or has been sitting unanswered for days. Assignee: - '@new' → fresh run without history - '@'→ the named agent with their context - '@human' → waits for a human (default if unclear) Purpose: set 'parent' to the task this one serves (task_create, task_update). The run of a task is told the chain up to the goal of the unit — so split work into sub-tasks under the task they serve instead of loose tasks. Schedules ('when' field): one-time ('at'), recurring ('every') or 'cron'. The time zone 'tz' is required (e.g. 'Europe/Berlin'). If 'when' is missing, the task is one-time and due immediately. """; public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", "properties": { "action": { "type": "string", "enum": ["task_create", "task_find", "task_list", "task_update", "task_comment"], "description": "The action to execute. 'task_find' searches open tasks with similar titles — always call before task_create." }, "id": { "type": "string", "description": "Task ID. Required for task_update and task_comment." }, "title": { "type": "string", "description": "Task title. Required for task_create and task_find." }, "force": { "type": "boolean", "description": "task_create: create a second task even if a matching open task exists. Only use if it is a different issue." }, "body": { "type": "string", "description": "task_create: description/assignment — passed as task instructions to executing agent. Not in task_update: the body is only ever added to, with task_comment." }, "assignee": { "type": "string", "description": "@new | @ | @human. Default @human." }, "status": { "type": "string", "enum": ["backlog", "todo", "in_progress", "in_review", "done", "canceled", "blocked"], "description": "New status (task_update). 'blocked' reports a blocker." }, "type": { "type": "string", "enum": ["work", "approval", "human_input"], "description": "Type of task. Default 'work'." }, "priority": { "type": "integer", "description": "1 (low) to 5 (high). Default 3." }, "when": { "type": "object", "properties": { "kind": { "type": "string", "enum": ["at", "every", "cron"] }, "value": { "type": "string", "description": "Timestamp (ISO), interval (e.g. '30m') or cron expression." }, "tz": { "type": "string", "description": "IANA timezone, e.g. 'Europe/Berlin'. REQUIRED." } }, "description": "Schedule. If omitted, the task is one-time and due immediately." }, "require_approval": { "type": "boolean", "description": "If true, the task is only considered done after review." }, "acceptance": { "type": "string", "description": "Acceptance criteria against which the result is verified. In task_update only by the task's creator." }, "parent": { "type": "string", "description": "ID of the task this one serves (its purpose — unlike blocked_by, which is a precondition). Empty string removes it (task_update)." }, "blocked_by": { "type": "array", "items": { "type": "string" }, "description": "IDs of tasks that must be completed first." }, "onlyWhenMarketOpen": { "type": "boolean", "description": "Only trigger schedule when market is open." }, "text": { "type": "string", "description": "Comment text (task_comment) — result or feedback." }, "reopen": { "type": "boolean", "description": "task_comment: set task back to 'todo' so it runs again." }, "search": { "type": "string", "description": "Free-text search across title and description (task_list)." }, "limit": { "type": "integer", "description": "Maximum number of results in task_list. Default 50." } }, "required": ["action"] } """).RootElement.Clone(); public async Task ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { if (context.Tasks is not { } repository) return ToolResult.Fail("The taskboard is not available for this instance."); if (string.IsNullOrWhiteSpace(context.SharedWorkspacePath)) return ToolResult.Fail("No shared workspace configured — the taskboard requires one."); var tasksDir = Path.Combine(context.SharedWorkspacePath, "tasks"); var board = new TaskboardService(repository, tasksDir); var action = GetString(input, "action"); if (string.IsNullOrWhiteSpace(action)) return ToolResult.Fail("'action' is required."); // #683: A task for another agent is a way of reaching it — the same address book // applies as for a message, otherwise it could be walked around. if (action is "task_create" or "task_update" && CheckAssignee(GetString(input, "assignee"), context) is { } outOfReach) return ToolResult.Fail(outOfReach); // #715: A new task for someone above is free text going up — the same way as a message. if (action is "task_create" or "task_update" && Upward(TaskAssignee.AgentId(GetString(input, "assignee") ?? ""), UpwardKind.Message, context) is { } notUp) return ToolResult.Fail(notUp); // #684: The boards this agent sees; null means all of them (no units). var visible = TaskBoard.VisibleTo(context.Organisation, context.AgentId); try { return action switch { "task_create" => await CreateAsync(input, board, context.AgentId, visible, context, ct), "task_find" => await FindAsync(input, board, visible, ct), "task_list" => await ListAsync(input, board, visible, ct), "task_update" => await UpdateAsync(input, board, visible, context, ct), "task_comment" => await CommentAsync(input, board, context.AgentId, visible, context, ct), _ => ToolResult.Fail($"Unknown action: {action}") }; } catch (Exception ex) { context.Logger.LogError(ex, "Error in taskboard tool for action {Action}", action); return ToolResult.Fail($"Error: {ex.Message}"); } } private static string? CheckAssignee(string? assignee, AgentToolContext context) { if (context.Organisation is not { HasUnits: true } org || string.IsNullOrWhiteSpace(assignee)) return null; if (TaskAssignee.KindOf(assignee) == TaskAssigneeKind.Human) return null; var target = TaskAssignee.AgentId(assignee); if (target.Length == 0 || string.Equals(target, context.AgentId, StringComparison.OrdinalIgnoreCase)) return null; if (org.CanAddress(context.AgentId, target)) return null; var lead = org.EffectiveLeaderOf(context.AgentId); return $"'{target}' is not in your address book, so you cannot assign tasks to it. " + (lead is not null ? $"Assign it to your lead '{lead.AgentId}', who can pass it on." : "Assign it to @human."); } /// /// #715: The way up, for a task whose assignee is . Tasks /// for a human or for the agent itself are never "up". /// private static string? Upward(string targetAgentId, UpwardKind kind, AgentToolContext context) => targetAgentId.Length == 0 ? null : ReportingLine.CheckUpward(context.Organisation, context.Trust, context.AgentId, targetAgentId, kind); /// #715: The agent the task's words and changes reach — its assignee. private static string AssigneeOf(TaskItem task) => TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human ? "" : TaskAssignee.AgentId(task.Assignee); // ─── Aktionen ─── /// /// #674: Searches open tasks with similar titles — the question to ask before creating one. /// /// The free-text search in task_list does not answer it: it only finds literal /// matches and returns a list the agent has to interpret itself. Here the answer is in /// the first sentence. /// private static async Task FindAsync( JsonElement input, TaskboardService board, Func? visible, CancellationToken ct) { var title = GetString(input, "title") ?? GetString(input, "search"); if (string.IsNullOrWhiteSpace(title)) return ToolResult.Fail("'title' is required — what should be searched for?"); var treffer = await SucheAehnlicheAsync(board, title, null, visible, ct); if (treffer.Count == 0) return ToolResult.Ok($"No open task resembles \"{title.Trim()}\". You can create it with task_create."); var sb = new StringBuilder(); sb.AppendLine($"{treffer.Count} open task(s) resemble \"{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 ? "same issue" : "related")}" + $" | Assignee: {task.Assignee} | Priority: {task.Priority}" + $" | open since {task.CreatedAt:dd.MM. HH:mm}"); } sb.AppendLine(); sb.Append("Append your observation to the existing task using task_comment instead of creating a second one."); return ToolResult.Ok(sb.ToString()); } /// /// Offene Aufgaben mit verwandtem Titel, engste zuerst — nur auf den Brettern, die der /// Agent sieht (#684). Eine aehnliche Aufgabe der Buchhaltung haelt keine Marketing-Aufgabe /// auf, und die Ablehnung verraete sonst, was dort liegt. /// private static async Task> SucheAehnlicheAsync( TaskboardService board, string title, string? exceptId, Func? visible, CancellationToken ct) { // Uncapped (#716): with a limit of 500, a duplicate among older, low-priority tasks // stayed invisible once the board grew beyond that. var alle = await board.ListAllAsync(ct); return TaskSimilarity.FindOpen(visible is null ? alle : alle.Where(visible).ToList(), title, exceptId); } /// /// #714: A parent must exist, be on a board the agent sees, and must not lie below the /// task itself — a circle would make the chain endless. /// private static async Task CheckParentAsync( string? taskId, string parentId, TaskboardService board, Func? visible, CancellationToken ct) { if (!await CanSeeAsync(board, parentId, visible, ct) || await board.GetAsync(parentId, ct) is null) return $"No task found with ID '{parentId}' to serve as parent."; if (taskId is not null && await GoalChain.WouldCycleAsync(taskId, parentId, board.GetAsync, ct)) return $"'{parentId}' lies below '{taskId}' (or is the task itself) — a task cannot serve itself."; return null; } /// /// #684: A task on a board the agent does not see is answered like one that does not /// exist — the answer must not tell that it is there. /// private static async Task CanSeeAsync(TaskboardService board, string id, Func? visible, CancellationToken ct) => visible is null || (await board.GetAsync(id, ct) is { } task && visible(task)); private static async Task CreateAsync( JsonElement input, TaskboardService board, string author, Func? visible, AgentToolContext context, CancellationToken ct) { var title = GetString(input, "title"); if (string.IsNullOrWhiteSpace(title)) return ToolResult.Fail("'title' is required."); if (!TryReadWhen(input, out var when, out var whenError)) return ToolResult.Fail(whenError!); var parent = GetString(input, "parent")?.Trim() ?? ""; if (parent.Length > 0 && await CheckParentAsync(null, parent, board, visible, ct) is { } parentProblem) return ToolResult.Fail(parentProblem); // #674: The same finding a second time is not a second task. It is recorded on the // existing one — so that later one can see how often and for how long. if (GetBool(input, "force") != true) { var dublette = (await SucheAehnlicheAsync(board, title, null, visible, ct)) .FirstOrDefault(t => t.Match == TaskTitleMatch.Duplicate).Task; if (dublette is not null) { // #715: The note carries the agent's title — free text, so it stays off the // tasks of someone above when the way up is closed to this agent. if (Upward(AssigneeOf(dublette), UpwardKind.Comment, context) is not null) return ToolResult.Fail( $"Not created: {dublette.Id} \"{dublette.Title}\" has been open since {dublette.CreatedAt:dd.MM. HH:mm} " + "and refers to the same issue."); await board.AddCommentAsync( dublette.Id, author, $"Observed again — {author} wanted to create the same task: \"{title.Trim()}\"", ct, context.RunId); return ToolResult.Fail( $"Not created: {dublette.Id} \"{dublette.Title}\" has been open since {dublette.CreatedAt:dd.MM. HH:mm} " + $"and refers to the same issue. Your reoccurrence was recorded there.\n" + $"Add details using task_comment, or use force=true if this is truly a different issue."); } } var definition = new TaskItem { Title = title.Trim(), Body = GetString(input, "body")?.Trim() ?? "", Status = TaskItemStatus.Todo, Type = TaskText.Type(GetString(input, "type")), Priority = GetInt(input, "priority") ?? 3, Assignee = GetString(input, "assignee")?.Trim() is { Length: > 0 } a ? a : TaskAssignee.Human, When = when, RequireApproval = GetBool(input, "require_approval") ?? false, Acceptance = GetString(input, "acceptance")?.Trim() ?? "", BlockedBy = GetStringList(input, "blocked_by"), OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false, // #684: decides the board when the task is for a human or a fresh run. CreatedBy = author, ParentId = parent, // #697: Where it came from — the engine's knowledge, never an argument of the agent. OriginRunId = context.RunId ?? "", OriginTaskId = context.RunTaskId ?? "" }; var created = await board.CreateAsync(definition, ct); return ToolResult.Ok( $"Task created: {created.Id} — {created.Title}\n" + $"Status: {TaskText.Of(created.Status)} | Assignee: {created.Assignee} | " + $"Priority: {created.Priority}{FormatWhen(created.When)}" + Hinweis(created)); } private static async Task ListAsync( JsonElement input, TaskboardService board, Func? visible, CancellationToken ct) { var query = new TaskQuery { Status = ParseStatusFilter(GetString(input, "status")), Assignee = GetString(input, "assignee"), Search = GetString(input, "search"), Limit = GetInt(input, "limit") ?? 50 }; var tasks = await board.ListAsync(query, visible, ct); if (tasks.Count == 0) return ToolResult.Ok("No matching tasks."); return ToolResult.Ok(Render(tasks)); } private static async Task UpdateAsync( JsonElement input, TaskboardService board, Func? visible, AgentToolContext context, CancellationToken ct) { var id = GetString(input, "id"); if (string.IsNullOrWhiteSpace(id)) return ToolResult.Fail("'id' is required."); if (!await CanSeeAsync(board, id.Trim(), visible, ct)) return ToolResult.Fail($"No task found with ID '{id}'."); // #715: No changes of status or fields on the tasks of someone above. var existing = await board.GetAsync(id.Trim(), ct); if (existing is not null && Upward(AssigneeOf(existing), UpwardKind.Change, context) is { } notUp) return ToolResult.Fail(notUp); // #677/#697: The body holds the order and every comment on it. Replacing it would erase // the trail back to the original — it is only ever added to. if (input.TryGetProperty("body", out _)) return ToolResult.Fail("The body of a task is not replaced — it holds the order and the whole discussion. " + "Add to it with task_comment."); // #677: Whoever is measured against the criteria does not get to move them. if (existing is not null && input.TryGetProperty("acceptance", out _) && !string.Equals(existing.CreatedBy, context.AgentId, StringComparison.OrdinalIgnoreCase)) return ToolResult.Fail("Only the task's creator changes its acceptance criteria. Comment with task_comment " + "if you think they should change."); if (!TryReadWhen(input, out var when, out var whenError)) return ToolResult.Fail(whenError!); var hasWhen = input.TryGetProperty("when", out _); var parent = GetString(input, "parent")?.Trim(); if (parent is { Length: > 0 } && await CheckParentAsync(id.Trim(), parent, board, visible, ct) is { } parentProblem) return ToolResult.Fail(parentProblem); var result = await board.UpdateAsync(id.Trim(), current => current with { Title = GetString(input, "title")?.Trim() ?? current.Title, Status = ParseStatusFilter(GetString(input, "status")) ?? current.Status, Type = input.TryGetProperty("type", out _) ? TaskText.Type(GetString(input, "type")) : current.Type, Priority = GetInt(input, "priority") ?? current.Priority, Assignee = GetString(input, "assignee")?.Trim() ?? current.Assignee, When = hasWhen ? when : current.When, RequireApproval = GetBool(input, "require_approval") ?? current.RequireApproval, Acceptance = GetString(input, "acceptance")?.Trim() ?? current.Acceptance, BlockedBy = input.TryGetProperty("blocked_by", out _) ? GetStringList(input, "blocked_by") : current.BlockedBy, OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? current.OnlyWhenMarketOpen, ParentId = parent ?? current.ParentId }, ct); if (result is null) return ToolResult.Fail($"No task found with ID '{id}'."); return ToolResult.Ok( $"Task {result.Id} updated.\n" + $"Status: {TaskText.Of(result.Status)} | Assignee: {result.Assignee} | Priority: {result.Priority}" + Hinweis(result)); } private static async Task CommentAsync( JsonElement input, TaskboardService board, string author, Func? visible, AgentToolContext context, CancellationToken ct) { var id = GetString(input, "id"); if (string.IsNullOrWhiteSpace(id)) return ToolResult.Fail("'id' is required."); if (!await CanSeeAsync(board, id.Trim(), visible, ct)) return ToolResult.Fail($"No task found with ID '{id}'."); // #715: A comment goes up one level at most, and only from the standard tier; reopening // is a change of status, which never goes up. if (await board.GetAsync(id.Trim(), ct) is not { } target) return ToolResult.Fail($"No task found with ID '{id}'."); if (Upward(AssigneeOf(target), GetBool(input, "reopen") == true ? UpwardKind.Change : UpwardKind.Comment, context) is { } notUp) return ToolResult.Fail(notUp); var text = GetString(input, "text"); if (string.IsNullOrWhiteSpace(text)) return ToolResult.Fail("'text' is required — what should be commented?"); // #715: A comment is part of the body, and the body goes into the assignee's next run. From // an agent that reads foreign content, on someone else's task, it arrives framed as data. var commentText = string.Equals(AssigneeOf(target), author, StringComparison.OrdinalIgnoreCase) ? text.Trim() : ReportingLine.Frame(context.Trust, author, text.Trim()); var result = await board.AddCommentAsync(id.Trim(), author, commentText, ct, context.RunId); if (result is null) return ToolResult.Fail($"No task found with ID '{id}'."); if (GetBool(input, "reopen") == true && result.Status != TaskItemStatus.Todo) { result = await board.UpdateAsync(id.Trim(), c => c with { Status = TaskItemStatus.Todo }, ct); return ToolResult.Ok($"Comment appended and task {id} reopened (todo)."); } return ToolResult.Ok($"Comment appended to task {id}."); } // ─── Darstellung ─── /// #697: "amazon-lead, while working on t-3, run 4f…" — or null for a task from a human. private static string? Origin(TaskItem task) { var parts = new List(); if (!string.IsNullOrWhiteSpace(task.CreatedBy)) parts.Add(task.CreatedBy); if (!string.IsNullOrWhiteSpace(task.OriginTaskId)) parts.Add($"while working on {task.OriginTaskId}"); if (!string.IsNullOrWhiteSpace(task.OriginRunId)) parts.Add($"run {task.OriginRunId}"); return parts.Count == 0 ? null : string.Join(", ", parts); } private static string Render(IReadOnlyList tasks) { var sb = new StringBuilder(); sb.AppendLine($"{tasks.Count} task(s), highest priority first:"); sb.AppendLine(); var shown = 0; foreach (var task in tasks) { var block = new StringBuilder(); block.Append($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}"); block.AppendLine(); block.AppendLine($" Assignee: {task.Assignee} | Priority: {task.Priority}{FormatWhen(task.When)}"); if (!string.IsNullOrWhiteSpace(task.ParentId)) block.AppendLine($" Serves: {task.ParentId}"); // #697: The origin travels with the task; the content stays where it is. if (Origin(task) is { } origin) block.AppendLine($" Arose from: {origin}"); if (task.BlockedBy.Count > 0) block.AppendLine($" Waiting for: {string.Join(", ", task.BlockedBy)}"); if (sb.Length + block.Length > MaxResultChars) { sb.AppendLine($"[… {tasks.Count - shown} more omitted. Narrow down your search.]"); break; } sb.Append(block); shown++; } return sb.ToString().TrimEnd(); } private static string FormatWhen(TaskWhen? when) => when is null ? "" : $" | Schedule: {TaskText.Of(when.Kind)} {when.Value} ({when.TimeZone})"; // ─── Eingabe lesen ─── private static bool TryReadWhen(JsonElement input, out TaskWhen? when, out string? error) { when = null; error = null; if (!input.TryGetProperty("when", out var w) || w.ValueKind != JsonValueKind.Object) return true; // kein Termin ist gültig (einmalige Aufgabe) var kindText = w.TryGetProperty("kind", out var k) ? k.GetString() : null; var kind = TaskText.WhenKind(kindText); if (kind is null) { error = "'when.kind' must be 'at', 'every', or 'cron'."; return false; } var value = w.TryGetProperty("value", out var v) ? v.GetString()?.Trim() : null; if (string.IsNullOrWhiteSpace(value)) { error = "'when.value' is required (timestamp, interval, or cron expression)."; return false; } var tz = w.TryGetProperty("tz", out var t) ? t.GetString()?.Trim() : null; if (string.IsNullOrWhiteSpace(tz)) { error = "'when.tz' is required — a schedule without a timezone is ambiguous (e.g. 'Europe/Berlin')."; return false; } var candidate = new TaskWhen { Kind = kind.Value, Value = value, TimeZone = tz }; // #597: Ein Termin, der nie feuern kann, wird gar nicht erst gespeichert. if (TaskSchedule.ValidateWhen(candidate) is { } problem) { error = problem; return false; } when = candidate; return true; } /// #598: Recurring with @agent - notify once on creation about costs. private static string Hinweis(TaskItem task) => TaskHinweise.WachsenderKontext(task) is { } hinweis ? "\n" + hinweis : ""; private static TaskItemStatus? ParseStatusFilter(string? value) => string.IsNullOrWhiteSpace(value) ? null : TaskText.Status(value); private static string? GetString(JsonElement input, string name) => input.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; private static int? GetInt(JsonElement input, string name) => input.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number ? value.GetInt32() : null; private static bool? GetBool(JsonElement input, string name) => input.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False ? value.GetBoolean() : null; private static IReadOnlyList GetStringList(JsonElement input, string name) { if (!input.TryGetProperty(name, out var arr) || arr.ValueKind != JsonValueKind.Array) return []; return arr.EnumerateArray() .Where(e => e.ValueKind == JsonValueKind.String) .Select(e => e.GetString()!) .Where(s => !string.IsNullOrWhiteSpace(s)) .Select(s => s.Trim()) .ToList(); } }