Besprechung als eigenes Mittel: Termin, Tagesordnung, eine Runde je Teilnehmer, Protokoll (#686)

Aufgabentyp meeting mit Teilnehmerliste (Frontmatter, SQLite, Taskboard-Werkzeug,
WebUI). MeetingRunner: jeder Teilnehmer einmal in Reihenfolge, Beitraege
gerahmt als Daten des Verfassers, danach schliesst die Leitung mit Ergebnis und
Aufgaben. Protokoll schreibt die Instanz in den Bereich der Einheit der Leitung.
Teilnehmer nur aus dem Adressbuch der Leitung, hoechstens 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-28 13:12:13 +02:00
co-authored by Claude Opus 5.5
parent 2df2fc476f
commit f2bf8b5b59
19 changed files with 684 additions and 25 deletions
@@ -93,8 +93,8 @@ public sealed class TaskboardTool : IAgentTool
},
"type": {
"type": "string",
"enum": ["work", "approval", "human_input"],
"description": "Type of task. Default 'work'."
"enum": ["work", "approval", "human_input", "meeting"],
"description": "Type of task. Default 'work'. 'meeting': the assignee chairs, each of 'participants' gets one turn with the agenda (body) and the contributions so far, then the chair writes the result and creates the tasks for it. Minutes are kept in the chair's unit workspace."
},
"priority": {
"type": "integer",
@@ -134,6 +134,11 @@ public sealed class TaskboardTool : IAgentTool
"items": { "type": "string" },
"description": "IDs of tasks that must be completed first."
},
"participants": {
"type": "array",
"items": { "type": "string" },
"description": "Meeting only: agent IDs in the order they speak. Only agents the chair can address; the chair itself is not listed."
},
"onlyWhenMarketOpen": {
"type": "boolean",
"description": "Only trigger schedule when market is open."
@@ -328,6 +333,12 @@ public sealed class TaskboardTool : IAgentTool
if (!TryReadFill(input, when, null, out var fillBy, out var estimate, out var fillError))
return ToolResult.Fail(fillError!);
var type = TaskText.Type(GetString(input, "type"));
var participants = GetStringList(input, "participants");
var assignee = GetString(input, "assignee")?.Trim() is { Length: > 0 } chosen ? chosen : TaskAssignee.Human;
if (MeetingProblem(type, participants, assignee, context) is { } meetingProblem)
return ToolResult.Fail(meetingProblem);
// #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)
@@ -361,9 +372,10 @@ public sealed class TaskboardTool : IAgentTool
Title = title.Trim(),
Body = GetString(input, "body")?.Trim() ?? "",
Status = TaskItemStatus.Todo,
Type = TaskText.Type(GetString(input, "type")),
Type = type,
Priority = GetInt(input, "priority") ?? 3,
Assignee = GetString(input, "assignee")?.Trim() is { Length: > 0 } a ? a : TaskAssignee.Human,
Assignee = assignee,
Participants = type == TaskItemType.Meeting ? participants : [],
When = when,
RequireApproval = GetBool(input, "require_approval") ?? false,
Acceptance = GetString(input, "acceptance")?.Trim() ?? "",
@@ -386,6 +398,20 @@ public sealed class TaskboardTool : IAgentTool
$"Priority: {created.Priority}{FormatWhen(created.When)}" + Hinweis(created));
}
/// <summary>
/// #686: A meeting needs an agent in the chair and participants the chair can address — the
/// circle comes from the structure, not from whoever writes the invitation.
/// </summary>
private static string? MeetingProblem(TaskItemType type, IReadOnlyList<string> participants, string assignee, AgentToolContext context)
{
if (type != TaskItemType.Meeting)
return participants.Count > 0 ? "'participants' only belongs to a meeting (type 'meeting')." : null;
var chair = TaskAssignee.AgentId(assignee);
if (string.IsNullOrWhiteSpace(chair))
return "A meeting needs an agent in the chair: set 'assignee' to '@new:<agent>'.";
return MeetingRunner.Refusal(participants, chair, context.Organisation);
}
private static async Task<ToolResult> ListAsync(
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
{
@@ -441,6 +467,15 @@ public sealed class TaskboardTool : IAgentTool
if (!TryReadFill(input, hasWhen ? when : existing?.When, existing, out var fillBy, out var estimate, out var fillError))
return ToolResult.Fail(fillError!);
if (existing is not null)
{
var type = input.TryGetProperty("type", out _) ? TaskText.Type(GetString(input, "type")) : existing.Type;
var participants = input.TryGetProperty("participants", out _) ? GetStringList(input, "participants") : existing.Participants;
var assignee = GetString(input, "assignee")?.Trim() ?? existing.Assignee;
if (MeetingProblem(type, participants, assignee, context) is { } meetingProblem)
return ToolResult.Fail(meetingProblem);
}
var result = await board.UpdateAsync(id.Trim(), current => current with
{
Title = GetString(input, "title")?.Trim() ?? current.Title,
@@ -452,6 +487,7 @@ public sealed class TaskboardTool : IAgentTool
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,
Participants = input.TryGetProperty("participants", out _) ? GetStringList(input, "participants") : current.Participants,
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? current.OnlyWhenMarketOpen,
ParentId = parent ?? current.ParentId,
FillBy = fillBy,
@@ -539,6 +575,8 @@ public sealed class TaskboardTool : IAgentTool
block.AppendLine($" Arose from: {origin}");
if (task.BlockedBy.Count > 0)
block.AppendLine($" Waiting for: {string.Join(", ", task.BlockedBy)}");
if (task.Type == TaskItemType.Meeting)
block.AppendLine($" Meeting with: {string.Join(", ", task.Participants)} (chair: {task.Assignee})");
// #717: Fill work waits for room, not for a time — say so, or it looks forgotten.
if (task.IsFillWork)
block.AppendLine($" Fill work: runs when the budget has room, at the latest by {task.FillBy:yyyy-MM-dd HH:mm} UTC");