- Werkzeugkontext kennt Lauf und Aufgabe des Laufs (RunId, RunTaskId); die Delegationskette traegt den Lauf des Aufrufers weiter (RunId, CallerRunId) - Aufgaben tragen origin_run / origin_task (Datei, DB, Taskboard "Arose from"), gestempelt, nie vom Agenten behauptet - Kommentare nennen den Lauf, in dem sie geschrieben wurden - Laufbeleg: CallerRunId; Laufansicht fuehrt zu "Delegiert von" und "Delegiert an" - #677: task_update ersetzt den Rumpf nicht mehr, Abnahmekriterien aendert nur der Ersteller; Kommentare der Fremdinhalt-Stufe auf fremden Aufgaben kommen gerahmt an - WebUI: Herkunft auf der Aufgabenkarte mit Link zum Lauf Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
616 lines
29 KiB
C#
616 lines
29 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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 <c>assignee: @<agent></c>) statt über rekursive Nachrichten —
|
|
/// das ist strukturell zyklenfrei. Wann eine fällige Aufgabe tatsächlich läuft,
|
|
/// entscheidet der Scanner, nicht dieses Tool.
|
|
/// </summary>
|
|
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='@<agentId>' —
|
|
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
|
|
- '@<agentId>'→ 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 | @<agentId> | @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<ToolResult> 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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// #715: The way up, for a task whose assignee is <paramref name="targetAgentId"/>. Tasks
|
|
/// for a human or for the agent itself are never "up".
|
|
/// </summary>
|
|
private static string? Upward(string targetAgentId, UpwardKind kind, AgentToolContext context)
|
|
=> targetAgentId.Length == 0
|
|
? null
|
|
: ReportingLine.CheckUpward(context.Organisation, context.Trust, context.AgentId, targetAgentId, kind);
|
|
|
|
/// <summary>#715: The agent the task's words and changes reach — its assignee.</summary>
|
|
private static string AssigneeOf(TaskItem task)
|
|
=> TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human ? "" : TaskAssignee.AgentId(task.Assignee);
|
|
|
|
// ─── Aktionen ───
|
|
|
|
/// <summary>
|
|
/// #674: Searches open tasks with similar titles — the question to ask before creating one.
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
private static async Task<ToolResult> FindAsync(
|
|
JsonElement input, TaskboardService board, Func<TaskItem, bool>? 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());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static async Task<List<(TaskItem Task, TaskTitleMatch Match)>> SucheAehnlicheAsync(
|
|
TaskboardService board, string title, string? exceptId, Func<TaskItem, bool>? 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
private static async Task<string?> CheckParentAsync(
|
|
string? taskId, string parentId, TaskboardService board, Func<TaskItem, bool>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
private static async Task<bool> CanSeeAsync(TaskboardService board, string id, Func<TaskItem, bool>? visible, CancellationToken ct)
|
|
=> visible is null || (await board.GetAsync(id, ct) is { } task && visible(task));
|
|
|
|
private static async Task<ToolResult> CreateAsync(
|
|
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? 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<ToolResult> ListAsync(
|
|
JsonElement input, TaskboardService board, Func<TaskItem, bool>? 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<ToolResult> UpdateAsync(
|
|
JsonElement input, TaskboardService board, Func<TaskItem, bool>? 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<ToolResult> CommentAsync(
|
|
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? 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 ───
|
|
|
|
/// <summary>#697: "amazon-lead, while working on t-3, run 4f…" — or <c>null</c> for a task from a human.</summary>
|
|
private static string? Origin(TaskItem task)
|
|
{
|
|
var parts = new List<string>();
|
|
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<TaskItem> 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;
|
|
}
|
|
|
|
/// <summary>#598: Recurring with @agent - notify once on creation about costs.</summary>
|
|
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<string> 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();
|
|
}
|
|
}
|