Entscheidungsinstanz an der Spitze: Mensch, Agent oder Mensch zuerst mit Frist (#690)

- cases.top in instance.json: Human | Agent | HumanFirst, agentId, humanFirstHours
- TopRole: der entscheidende Agent steht in keiner Einheit, liest keine Fremdinhalte
  und hat nur Taskboard, Escalation, Memory, FileRW, AgentComm; geprueft beim Setzen,
  bei jeder Zustellung und vor jeder Entscheidung
- CaseDesk: was bisher an der Spitze beim Menschen landete, geht nach Einstellung an
  den Agenten; Tagesdeckel und Frist gehen weiter an den Menschen. handle verwehrt,
  forward geht an den Menschen. Mensch zuerst: Uebergabe nach Ablauf, eigene Frist.
  Der Mensch kann einen Fall des Agenten selbst entscheiden.
- Beschluesse des Agenten: ByTopAgent, zum Lesen vorgelegt; jeder Beschluss laesst
  sich mit Begruendung zuruecknehmen und bindet dann niemanden mehr
- Briefing fuer den Agenten an der Spitze, auch ohne Einheiten
- OrgAdministration.SetTop mit Audit; Werkzeugsperre und Verschiebesperre
- WebUI Vorgaenge: Reiter Spitze, Lesen/Zuruecknehmen, Zaehler; i18n en/de
- Tests: TopRoleTests (15), OrgAdministrationTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-29 09:27:26 +02:00
co-authored by Claude Opus 5.5
parent e22edb418a
commit e8964e3c74
16 changed files with 1020 additions and 26 deletions
@@ -1,5 +1,6 @@
using System.Text.Json;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Cases;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Microsoft.Extensions.Logging;
@@ -194,10 +195,52 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
problems.Add($"There is no agent '{agentId}'.");
if (!string.IsNullOrWhiteSpace(targetUnitId) && chart.Unit(targetUnitId) is null)
problems.Add($"There is no unit '{targetUnitId}'.");
if (!string.IsNullOrWhiteSpace(targetUnitId) && IsTopAgent(agentId))
problems.Add($"'{agentId}' is the deciding agent at the top and stands above all units. Set another top first.");
return Preview([agentId], NullIfEmpty(targetUnitId), movingUnit: null, problems);
}
// ─── the top (#690) ───
private bool IsTopAgent(string agentId)
=> host.Instance.Cases.Top.UsesAgent && Same(host.Instance.Cases.Top.AgentId?.Trim(), agentId);
/// <summary>
/// #690: Who decides at the top — a human, an agent, or the human first with the agent behind.
/// An agent has to fit the role (<see cref="TopRole.Unfit"/>) when it is set; a change of
/// the top is a change of rights and goes into the audit log with its reason.
/// </summary>
public AdminResult SetTop(TopMode mode, string? agentId, double? humanFirstHours = null, string reason = "")
{
agentId = NullIfEmpty(agentId);
if (mode != TopMode.Human)
{
if (agentId is null)
return AdminResult.Failure("Name the agent that decides at the top.");
if (TopRole.Unfit(host.AgentAdmin.Find(agentId), agentId) is { } unfit)
return AdminResult.Failure(unfit);
agentId = host.AgentAdmin.Find(agentId)!.AgentId;
}
if (humanFirstHours is <= 0)
return AdminResult.Failure("The time the human has comes in hours, above zero.");
var top = host.Instance.Cases.Top;
top.Mode = mode;
top.AgentId = mode == TopMode.Human ? null : agentId;
if (humanFirstHours is { } hours)
top.HumanFirstHours = hours;
host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
Audit(agentId ?? "", "top_set", new { mode = mode.ToString(), agentId, top.HumanFirstHours }, reason);
return AdminResult.Success(mode switch
{
TopMode.Human => "A human decides at the top.",
TopMode.Agent => $"'{agentId}' decides at the top. Its decisions are presented to you to read.",
_ => $"A human decides at the top first; after {top.HumanFirst.TotalHours:0.##} h without an answer, '{agentId}' decides and presents it."
});
}
/// <summary>
/// Moves an agent. Tools beyond the new unit's ceiling are removed only with
/// <paramref name="dropExcessTools"/> — otherwise the move is refused and says which.
@@ -323,6 +366,10 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
/// </summary>
public string? CeilingRefusal(AgentConfig agent, string toolName)
{
// #690: The deciding agent decides, it does not carry out.
if (IsTopAgent(agent.AgentId) && !TopRole.AllowedTools.Contains(toolName))
return $"'{agent.AgentId}' is the deciding agent at the top and may not hold '{toolName}' — it decides, it does not carry out. " +
$"Allowed are {string.Join(", ", TopRole.AllowedTools.Order(StringComparer.OrdinalIgnoreCase))}.";
if (Chart.ToolCeiling(agent.UnitId) is not { } ceiling || ceiling.Contains(toolName))
return null;
return $"'{toolName}' is beyond the tool ceiling of the unit '{Chart.UnitOf(agent.AgentId)?.Name}'. " +