Rueckmeldung nach Vertrauensstufe: drei Wege nach oben (#715)

- IAgentTool.ReadsForeignContent: Werkzeuge mit Aussenkontakt erklaeren sich selbst
  (WebFetch, WebMonitor, DirectAPI, Mail, RocketChat, Telegram, TelegramClient,
  SocialMediaManager, FTP, Database); AgentConfig.TreatAsForeignReader kann nur verschaerfen
- ReportingLine: Stufe aus den Werkzeugen, "wer steht darueber", Pruefung des Wegs nach oben
  (nie Aenderungen an Aufgaben darueber, nur eine Ebene hoch, Fremdinhalt-Stufe gar kein
  Freitext) und Rahmung als untrusted_content
- Engine: Nachricht und Spawn nach oben geprueft, Text der Fremdinhalt-Stufe kommt gerahmt an
- Taskboard: task_create/update/comment, reopen und die Dubletten-Notiz folgen dem Berichtsweg
- Dispatcher: Aufgaben von Agenten der Fremdinhalt-Stufe gehen gerahmt in den Lauf
- TaskReports: Erledigt-Signal und Systemmeldung (in Pruefung, blockiert, abgebrochen) an den
  Auftraggeber, je Zustand einmal, gesammelt in einer offenen Aufgabe je Empfaenger
- OrgBriefing nennt den Berichtsweg; WebUI zeigt Stufe und Schalter am Agenten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 09:16:29 +02:00
co-authored by Claude Opus 5.5
parent e2ffe4b380
commit 3857a7cab2
33 changed files with 1234 additions and 30 deletions
@@ -1,5 +1,6 @@
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
@@ -166,6 +167,11 @@ public sealed class TaskboardTool : IAgentTool
&& 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);
@@ -173,11 +179,11 @@ public sealed class TaskboardTool : IAgentTool
{
return action switch
{
"task_create" => await CreateAsync(input, board, context.AgentId, visible, ct),
"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, ct),
"task_comment" => await CommentAsync(input, board, context.AgentId, 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}")
};
}
@@ -208,6 +214,19 @@ public sealed class TaskboardTool : IAgentTool
: "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>
@@ -280,7 +299,7 @@ public sealed class TaskboardTool : IAgentTool
=> 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, CancellationToken ct)
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? visible, AgentToolContext context, CancellationToken ct)
{
var title = GetString(input, "title");
if (string.IsNullOrWhiteSpace(title))
@@ -302,6 +321,13 @@ public sealed class TaskboardTool : IAgentTool
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()}\"",
@@ -358,7 +384,7 @@ public sealed class TaskboardTool : IAgentTool
}
private static async Task<ToolResult> UpdateAsync(
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, AgentToolContext context, CancellationToken ct)
{
var id = GetString(input, "id");
if (string.IsNullOrWhiteSpace(id))
@@ -366,6 +392,11 @@ public sealed class TaskboardTool : IAgentTool
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.
if (await board.GetAsync(id.Trim(), ct) is { } current
&& Upward(AssigneeOf(current), UpwardKind.Change, context) is { } notUp)
return ToolResult.Fail(notUp);
if (!TryReadWhen(input, out var when, out var whenError))
return ToolResult.Fail(whenError!);
var hasWhen = input.TryGetProperty("when", out _);
@@ -399,7 +430,7 @@ public sealed class TaskboardTool : IAgentTool
}
private static async Task<ToolResult> CommentAsync(
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? visible, CancellationToken ct)
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? visible, AgentToolContext context, CancellationToken ct)
{
var id = GetString(input, "id");
if (string.IsNullOrWhiteSpace(id))
@@ -407,6 +438,12 @@ public sealed class TaskboardTool : IAgentTool
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 { } target
&& 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?");