Verweis statt Nacherzaehlung: die Engine stempelt den Ursprung (#697, #677)

- 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>
This commit is contained in:
Richard
2026-09-25 09:27:47 +02:00
co-authored by Claude Opus 5.5
parent 3f90e0d0cd
commit f812dbd856
26 changed files with 493 additions and 47 deletions
@@ -75,7 +75,7 @@ public sealed class TaskboardTool : IAgentTool
},
"body": {
"type": "string",
"description": "Description/assignment — passed as task instructions to executing agent."
"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",
@@ -110,7 +110,7 @@ public sealed class TaskboardTool : IAgentTool
},
"acceptance": {
"type": "string",
"description": "Acceptance criteria against which the result is verified."
"description": "Acceptance criteria against which the result is verified. In task_update only by the task's creator."
},
"parent": {
"type": "string",
@@ -331,7 +331,7 @@ public sealed class TaskboardTool : IAgentTool
await board.AddCommentAsync(
dublette.Id, author,
$"Observed again — {author} wanted to create the same task: \"{title.Trim()}\"",
ct);
ct, context.RunId);
return ToolResult.Fail(
$"Not created: {dublette.Id} \"{dublette.Title}\" has been open since {dublette.CreatedAt:dd.MM. HH:mm} " +
@@ -355,7 +355,10 @@ public sealed class TaskboardTool : IAgentTool
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false,
// #684: decides the board when the task is for a human or a fresh run.
CreatedBy = author,
ParentId = parent
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);
@@ -393,10 +396,22 @@ public sealed class TaskboardTool : IAgentTool
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)
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 _);
@@ -408,7 +423,6 @@ public sealed class TaskboardTool : IAgentTool
var result = await board.UpdateAsync(id.Trim(), current => current with
{
Title = GetString(input, "title")?.Trim() ?? current.Title,
Body = GetString(input, "body")?.Trim() ?? current.Body,
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,
@@ -440,15 +454,21 @@ public sealed class TaskboardTool : IAgentTool
// #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)
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?");
var result = await board.AddCommentAsync(id.Trim(), author, text.Trim(), ct);
// #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}'.");
@@ -463,6 +483,19 @@ public sealed class TaskboardTool : IAgentTool
// ─── 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();
@@ -478,6 +511,9 @@ public sealed class TaskboardTool : IAgentTool
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)}");