Einheiten, Stufe 3: Verwaltung mit Umzug, Rechte-Obergrenze und Sammelwerten (#683)

OrgAdministration (App), damit WebUI und Desktop dieselben Regeln anwenden:
- Einheiten anlegen, umbenennen, Ziel setzen, loeschen (nur leer); Leitung
  setzen (nur ein Mitglied).
- Agent umziehen: haelt fest, seit wann und woher (UnitSinceUtc,
  PreviousUnitId); eine Leitung, die geht, laesst ihre Einheit ohne Leitung.
  Einheit umziehen: nie unter sich selbst; die Mitglieder bleiben in ihrer
  Einheit, ihre Position aendert sich, und das wird ihnen gesagt.
- Kein Umzug unter einem laufenden Agenten. Das Konzept sagt "bei gestoppter
  Instanz", damit niemand mitten im Lauf andere Rechte bekommt. Die Verwaltung
  laeuft in der Instanz und kann sie nicht stoppen; sie lehnt ab, solange ein
  betroffener Agent laeuft. Das dient demselben Zweck, nur enger.
- Rechte-Obergrenze: beim Schreiben geprueft (SetToolEnabled, SetToolSettings,
  und als Sicherheitsnetz Save() fuer den Desktop, der Werkzeuge direkt
  umschaltet). Obergrenze senken und in eine Obergrenze umziehen zeigen in
  der Vorschau, welche Werkzeuge wegfallen, und brauchen dafuer eine
  ausdrueckliche Bestaetigung.
- Sammelwerte (Tagesbudget, Tokens, Modell, whenExhausted) mit Vorschau fuer
  die Einheit samt Untereinheiten: ein Wert je Agent oder ein verteilter
  Gesamtbetrag im Verhaeltnis der bisherigen Werte. Ausreisser (Faktor 3 zum
  Median der anderen) sind markiert und standardmaessig nicht gewaehlt.
  Geschrieben wird nur, was gewaehlt ist; die Einheit merkt sich den Wert nur
  als Vorgabe fuer neue Mitglieder.
- Ein neuer Agent, der direkt in eine Einheit gelegt wird, bekommt deren
  Vorgaben; ein umgezogener behaelt seine Werte.
- Jede Strukturaenderung steht mit Begruendung im Audit (Quelle "admin",
  Werkzeug "Organisation").

Der Organisationsabschnitt nennt der Leitung jetzt auch, wer die Einheit
kuerzlich verlassen hat und wohin: Die abgebende Leitung wird unterrichtet,
nicht nur die aufnehmende.

Tests: OrgAdministrationTests (16). 1.333 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 12:48:34 +02:00
co-authored by Claude Opus 5.5
parent 626fc8d593
commit 68a432fec6
7 changed files with 912 additions and 2 deletions
+3
View File
@@ -214,6 +214,9 @@ public sealed class AppHost : IAsyncDisposable
/// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary> /// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary>
public AgentAdministration AgentAdmin => _agentAdmin.Value; public AgentAdministration AgentAdmin => _agentAdmin.Value;
/// <summary>#683: Units, moves, tool ceilings, unit-wide values. Stateless.</summary>
public OrgAdministration Organisation => new(this, LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation"));
/// <summary>Dienste der Instanz anlegen, entfernen, umschalten.</summary> /// <summary>Dienste der Instanz anlegen, entfernen, umschalten.</summary>
public ServiceAdministration ServiceAdmin => _serviceAdmin.Value; public ServiceAdministration ServiceAdmin => _serviceAdmin.Value;
@@ -1,6 +1,7 @@
using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances; using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Staging; using ClawdDotNet.Core.Staging;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services; namespace ClawdDotNet.App.Services;
@@ -9,7 +10,8 @@ public sealed record NewAgent(
string AgentId, string AgentId,
string DisplayName, string DisplayName,
string Model, string Model,
string Description = ""); string Description = "",
string? UnitId = null);
/// <summary> /// <summary>
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen. /// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
@@ -86,6 +88,10 @@ public sealed class AgentAdministration(AppHost host)
if (Find(agentId) is not null) if (Find(agentId) is not null)
return AdminResult.Failure($"Ein Agent mit der ID '{agentId}' existiert bereits."); return AdminResult.Failure($"Ein Agent mit der ID '{agentId}' existiert bereits.");
var unitId = string.IsNullOrWhiteSpace(request.UnitId) ? null : request.UnitId.Trim();
if (unitId is not null && host.Organisation.Current().Unit(unitId) is null)
return AdminResult.Failure($"There is no unit '{unitId}'.");
string agentDir; string agentDir;
try try
@@ -111,6 +117,14 @@ public sealed class AgentAdministration(AppHost host)
config.Model = request.Model; config.Model = request.Model;
config.Description = request.Description; config.Description = request.Description;
// #683: Placed into a unit, the agent starts with the unit's defaults.
if (unitId is not null)
{
config.UnitId = unitId;
config.UnitSinceUtc = DateTime.UtcNow;
host.Organisation.ApplyDefaultsToNewAgent(config);
}
host.Directories.SaveAgentSettings(agentDir, config); host.Directories.SaveAgentSettings(agentDir, config);
host.Instance.Agents.Add(config); host.Instance.Agents.Add(config);
@@ -181,6 +195,10 @@ public sealed class AgentAdministration(AppHost host)
if (enabled) if (enabled)
{ {
// #683: What the unit may not have, a member cannot be granted.
if (!agent.Tools.ContainsKey(toolName) && host.Organisation.CeilingRefusal(agent, toolName) is { } refused)
return AdminResult.Failure(refused);
if (!agent.Tools.ContainsKey(toolName)) if (!agent.Tools.ContainsKey(toolName))
agent.Tools[toolName] = new Dictionary<string, object?>(); agent.Tools[toolName] = new Dictionary<string, object?>();
} }
@@ -278,6 +296,10 @@ public sealed class AgentAdministration(AppHost host)
if (Find(agentId) is not { } agent) if (Find(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'."); return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
// #683: Settings for a tool the agent does not have yet grant it — the ceiling applies.
if (!agent.Tools.ContainsKey(toolName) && host.Organisation.CeilingRefusal(agent, toolName) is { } refused)
return AdminResult.Failure(refused);
// #641: Die Einstellungsmasken kennen die Rechte nicht und schreiben ein frisches // #641: Die Einstellungsmasken kennen die Rechte nicht und schreiben ein frisches
// Woerterbuch. Ohne diese Uebernahme loeschte jedes Speichern still "erlaubt" und // Woerterbuch. Ohne diese Uebernahme loeschte jedes Speichern still "erlaubt" und
// "verboten" - und damit die Rechte des Agenten. // "verboten" - und damit die Rechte des Agenten.
@@ -309,6 +331,22 @@ public sealed class AgentAdministration(AppHost host)
/// </summary> /// </summary>
public void Save() public void Save()
{ {
// #683: The desktop toggles tools directly on the configuration objects and only
// saves here. The ceiling is a rule of writing, so it is enforced here as well: a
// tool beyond it does not get saved.
var chart = host.Organisation.Current();
foreach (var agent in host.Instance.Agents)
{
if (chart.ToolCeiling(agent.UnitId) is not { } ceiling)
continue;
foreach (var tool in agent.Tools.Keys.Where(t => !ceiling.Contains(t)).ToList())
{
agent.Tools.Remove(tool);
host.LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation").LogWarning(
"Tool {Tool} removed from {AgentId}: beyond the tool ceiling of its unit", tool, agent.AgentId);
}
}
foreach (var agent in host.Instance.Agents) foreach (var agent in host.Instance.Agents)
PersistAgent(agent); PersistAgent(agent);
@@ -329,7 +367,7 @@ public sealed class AgentAdministration(AppHost host)
/// werden sie gelesen, und was in <c>AgentSettings.json</c> danebenstünde, überschriebe /// werden sie gelesen, und was in <c>AgentSettings.json</c> danebenstünde, überschriebe
/// der nächste Ladevorgang ohnehin.</para> /// der nächste Ladevorgang ohnehin.</para>
/// </summary> /// </summary>
private void PersistAgent(AgentConfig agent) internal void PersistAgent(AgentConfig agent)
{ {
// Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten // Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten
// Instanzdatei. Ihm hier einen Ordner anzulegen waere eine Nebenwirkung des // Instanzdatei. Ihm hier einen Ordner anzulegen waere eine Nebenwirkung des
@@ -0,0 +1,598 @@
using System.Text.Json;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>A unit-wide value that can be written into the members (#683).</summary>
public enum UnitSetting
{
DailyCostUsd,
DailyTokens,
Model,
WhenExhausted
}
/// <summary>How a unit-wide number is turned into per-agent values.</summary>
public enum UnitSettingMode
{
/// <summary>Every agent gets the value ("each gets 10 $").</summary>
PerAgent,
/// <summary>The value is a total, split in proportion to the current values ("the unit gets 100 $").</summary>
Distribute
}
/// <summary>One line of a preview: what would change at one agent.</summary>
public sealed record UnitSettingChange(
string AgentId,
string DisplayName,
string Current,
string Proposed,
bool Outlier,
bool SelectedByDefault);
/// <summary>What a move or a lowered ceiling would do before it is done.</summary>
public sealed record StructurePreview(
IReadOnlyList<string> AffectedAgents,
IReadOnlyDictionary<string, IReadOnlyList<string>> ToolsLost,
IReadOnlyList<string> Running,
IReadOnlyList<string> Problems)
{
public bool CanApply => Running.Count == 0 && Problems.Count == 0;
}
/// <summary>
/// #683: Units — create, rename, lead, move, limit — and unit-wide values written into the
/// members. The rules live here, not in a user interface, so the WebUI and the desktop
/// apply the same ones (like <see cref="AgentAdministration"/>).
///
/// <para><b>Three rules from the concept (section 3):</b></para>
/// <list type="number">
/// <item><b>No value overrides another at run time.</b> A unit-wide value is written into
/// the agents after a preview; the unit keeps it only as the default for agents that join.
/// Outliers — the specialist with a deliberately large budget — are marked and not
/// selected by default.</item>
/// <item><b>The tool ceiling is checked when writing.</b> A tool beyond it cannot be
/// granted; lowering it removes the tools from the agents, after a preview.</item>
/// <item><b>A move never happens under a running agent.</b> The concept says "with the
/// instance stopped", so that no agent gets different rights in the middle of a run. The
/// administration runs inside the instance and cannot stop it; it refuses while an affected
/// agent runs, which serves the same purpose more narrowly.</item>
/// </list>
///
/// <para>Every structural change is written to the audit log with its reason — a change of
/// structure is a change of rights (#205, #641).</para>
/// </summary>
public sealed class OrgAdministration(AppHost host, ILogger logger)
{
/// <summary>A value this far from the median of the others is an outlier.</summary>
public const decimal OutlierFactor = 3m;
private OrgChart Chart => OrgChart.From(host.Instance);
public OrgChart Current() => Chart;
// ─── units ───
public AdminResult CreateUnit(string id, string name, string? parentId, string? goal = null, string reason = "")
{
id = (id ?? "").Trim();
if (id.Length == 0 || id.Any(c => !(char.IsLetterOrDigit(c) || c is '-' or '_')))
return AdminResult.Failure("A unit id consists of letters, digits, '-' and '_'.");
if (string.IsNullOrWhiteSpace(name))
return AdminResult.Failure("A unit needs a name.");
if (Chart.Unit(id) is not null)
return AdminResult.Failure($"There is already a unit '{id}'.");
if (!string.IsNullOrWhiteSpace(parentId) && Chart.Unit(parentId) is null)
return AdminResult.Failure($"There is no unit '{parentId}' to put it under.");
host.Instance.Units.Add(new UnitConfig
{
Id = id,
Name = name.Trim(),
ParentId = string.IsNullOrWhiteSpace(parentId) ? null : parentId.Trim(),
Goal = goal?.Trim() ?? ""
});
Save(null, "unit_create", new { id, name, parentId, goal }, reason);
return AdminResult.Success($"Unit '{name.Trim()}' created.");
}
public AdminResult UpdateUnit(string unitId, string? name, string? goal, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
if (name is not null)
{
if (string.IsNullOrWhiteSpace(name))
return AdminResult.Failure("A unit needs a name.");
unit.Name = name.Trim();
}
if (goal is not null)
unit.Goal = goal.Trim();
Save(null, "unit_update", new { unitId, name, goal }, reason);
return AdminResult.Success($"Unit '{unit.Name}' updated.");
}
/// <summary>Only an empty unit can go — members and sub-units have to be moved first.</summary>
public AdminResult DeleteUnit(string unitId, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
var chart = Chart;
if (chart.MembersOf(unit.Id).Count > 0 || chart.Children(unit.Id).Count > 0)
return AdminResult.Failure($"Unit '{unit.Name}' still has members or sub-units. Move them first.");
host.Instance.Units.Remove(unit);
Save(null, "unit_delete", new { unitId }, reason);
return AdminResult.Success($"Unit '{unit.Name}' deleted.");
}
/// <summary>The lead must be a member of the unit. <c>null</c> leaves it without a lead.</summary>
public AdminResult SetLeader(string unitId, string? agentId, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
if (!string.IsNullOrWhiteSpace(agentId))
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
return AdminResult.Failure($"There is no agent '{agentId}'.");
if (!string.Equals(agent.UnitId, unit.Id, StringComparison.OrdinalIgnoreCase))
return AdminResult.Failure($"'{agentId}' is not a member of '{unit.Name}'. Move it there first.");
}
unit.LeaderAgentId = string.IsNullOrWhiteSpace(agentId) ? null : agentId.Trim();
Save(null, "unit_lead", new { unitId, agentId }, reason);
return AdminResult.Success(unit.LeaderAgentId is null
? $"'{unit.Name}' has no lead now; the lead above takes over."
: $"'{unit.LeaderAgentId}' leads '{unit.Name}'.");
}
// ─── moves ───
public StructurePreview PreviewMoveAgent(string agentId, string? targetUnitId)
{
var chart = Chart;
var problems = new List<string>();
if (chart.Agent(agentId) is null)
problems.Add($"There is no agent '{agentId}'.");
if (!string.IsNullOrWhiteSpace(targetUnitId) && chart.Unit(targetUnitId) is null)
problems.Add($"There is no unit '{targetUnitId}'.");
return Preview([agentId], NullIfEmpty(targetUnitId), movingUnit: null, problems);
}
/// <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.
/// A lead leaving its unit leaves it without a lead.
/// </summary>
public AdminResult MoveAgent(string agentId, string? targetUnitId, string reason, bool dropExcessTools = false)
{
targetUnitId = NullIfEmpty(targetUnitId);
var preview = PreviewMoveAgent(agentId, targetUnitId);
if (Refusal(preview, dropExcessTools) is { } refused)
return refused;
var agent = host.AgentAdmin.Find(agentId)!;
if (string.Equals(agent.UnitId, targetUnitId, StringComparison.OrdinalIgnoreCase))
return AdminResult.Success($"'{agentId}' is already there.");
var from = agent.UnitId;
if (FindUnit(from) is { } oldUnit
&& string.Equals(oldUnit.LeaderAgentId, agentId, StringComparison.OrdinalIgnoreCase))
oldUnit.LeaderAgentId = null;
DropTools(preview);
agent.PreviousUnitId = from;
agent.UnitId = targetUnitId;
agent.UnitSinceUtc = DateTime.UtcNow;
host.AgentAdmin.PersistAgent(agent);
Save(agentId, "agent_move", new { agentId, from, to = targetUnitId, toolsLost = preview.ToolsLost }, reason);
return AdminResult.Success($"'{agentId}' moved to {Describe(targetUnitId)}.");
}
public StructurePreview PreviewMoveUnit(string unitId, string? newParentId)
{
var chart = Chart;
var problems = new List<string>();
newParentId = NullIfEmpty(newParentId);
if (chart.Unit(unitId) is null)
problems.Add($"There is no unit '{unitId}'.");
else if (newParentId is not null)
{
if (chart.Unit(newParentId) is null)
problems.Add($"There is no unit '{newParentId}'.");
else if (string.Equals(newParentId, unitId, StringComparison.OrdinalIgnoreCase)
|| chart.Ancestors(newParentId).Any(u => string.Equals(u.Id, unitId, StringComparison.OrdinalIgnoreCase)))
problems.Add("A unit cannot be moved under itself or one of its own sub-units.");
}
var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).Select(a => a.AgentId).ToList();
return Preview(agents, targetUnitId: null, movingUnit: (unitId, newParentId), problems);
}
/// <summary>
/// Moves a unit with everything in it. Its members keep their unit; what changes is
/// what lies above — leads, reach and possibly the tool ceiling.
/// </summary>
public AdminResult MoveUnit(string unitId, string? newParentId, string reason, bool dropExcessTools = false)
{
newParentId = NullIfEmpty(newParentId);
var preview = PreviewMoveUnit(unitId, newParentId);
if (Refusal(preview, dropExcessTools) is { } refused)
return refused;
var unit = FindUnit(unitId)!;
var from = unit.ParentId;
DropTools(preview);
unit.ParentId = newParentId;
// The members changed position although not unit: tell them, like a move.
var now = DateTime.UtcNow;
foreach (var agentId in preview.AffectedAgents)
{
if (host.AgentAdmin.Find(agentId) is { } agent)
{
agent.UnitSinceUtc = now;
host.AgentAdmin.PersistAgent(agent);
}
}
Save(null, "unit_move", new { unitId, from, to = newParentId, toolsLost = preview.ToolsLost }, reason);
return AdminResult.Success($"Unit '{unit.Name}' moved under {Describe(newParentId)}.");
}
// ─── tool ceiling ───
public StructurePreview PreviewToolCeiling(string unitId, IReadOnlyCollection<string>? tools)
{
var chart = Chart;
var problems = new List<string>();
if (chart.Unit(unitId) is null)
problems.Add($"There is no unit '{unitId}'.");
var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).Select(a => a.AgentId).ToList();
return Preview(agents, targetUnitId: null, movingUnit: null, problems, ceiling: (unitId, tools));
}
/// <summary>
/// Sets the ceiling. Lowering it removes the tools beyond it from the members — shown by
/// <see cref="PreviewToolCeiling"/> first, and only with <paramref name="dropExcessTools"/>,
/// like a move. <c>null</c> lifts the ceiling.
/// </summary>
public AdminResult SetToolCeiling(
string unitId, IReadOnlyCollection<string>? tools, string reason, bool dropExcessTools = false)
{
var preview = PreviewToolCeiling(unitId, tools);
if (Refusal(preview, dropExcessTools) is { } refused)
return refused;
var unit = FindUnit(unitId)!;
DropTools(preview);
unit.ToolCeiling = tools?.Select(t => t.Trim()).Where(t => t.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
Save(null, "unit_ceiling", new { unitId, tools = unit.ToolCeiling, toolsLost = preview.ToolsLost }, reason);
return AdminResult.Success(unit.ToolCeiling is null
? $"'{unit.Name}' has no tool ceiling now."
: $"Tool ceiling of '{unit.Name}' set: {string.Join(", ", unit.ToolCeiling)}.");
}
/// <summary>
/// Whether an agent may be granted a tool — called by <see cref="AgentAdministration"/>
/// when writing. <c>null</c> means yes; otherwise the reason.
/// </summary>
public string? CeilingRefusal(AgentConfig agent, string toolName)
{
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}'. " +
"Raise the ceiling of the unit or move the agent.";
}
// ─── unit-wide values ───
/// <summary>
/// What writing a unit-wide value would change, per agent below the unit (sub-units
/// included). Outliers are marked and not selected by default: the specialist with a
/// deliberately large budget must not be overwritten by accident.
/// </summary>
public IReadOnlyList<UnitSettingChange> PreviewSetting(
string unitId, UnitSetting setting, string value, UnitSettingMode mode = UnitSettingMode.PerAgent)
{
var chart = Chart;
if (chart.Unit(unitId) is null)
return [];
var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).ToList();
var numeric = setting is UnitSetting.DailyCostUsd or UnitSetting.DailyTokens;
if (!numeric)
{
return agents.Select(a => new UnitSettingChange(
a.AgentId, a.DisplayName, CurrentText(a, setting), value.Trim(), Outlier: false,
SelectedByDefault: true)).ToList();
}
if (!decimal.TryParse(value, System.Globalization.NumberStyles.Number,
System.Globalization.CultureInfo.InvariantCulture, out var number) || number < 0)
return [];
var current = agents.Select(a => CurrentNumber(a, setting)).ToList();
var outliers = OutlierFlags(current);
IReadOnlyList<decimal> proposed;
if (mode == UnitSettingMode.Distribute)
{
// In proportion to the current values, so a specialist keeps its larger share.
// Without any current values, evenly.
var sum = current.Sum();
proposed = current.Select(c => sum > 0 ? number * c / sum : agents.Count == 0 ? 0 : number / agents.Count).ToList();
}
else
{
proposed = current.Select(_ => number).ToList();
}
return agents.Select((a, i) => new UnitSettingChange(
a.AgentId, a.DisplayName,
Format(current[i], setting), Format(proposed[i], setting),
outliers[i], SelectedByDefault: !outliers[i])).ToList();
}
/// <summary>
/// Writes the values chosen from a preview into the agents, one audit entry each, and —
/// with <paramref name="rememberAsDefault"/> — keeps the value as the unit's default for
/// agents that join.
/// </summary>
public AdminResult ApplySetting(
string unitId, UnitSetting setting, IReadOnlyDictionary<string, string> valuesByAgent,
string reason, string? defaultValue = null)
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
var chart = Chart;
var below = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id))
.Select(a => a.AgentId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var written = 0;
foreach (var (agentId, value) in valuesByAgent)
{
if (!below.Contains(agentId) || host.AgentAdmin.Find(agentId) is not { } agent)
return AdminResult.Failure($"'{agentId}' is not below '{unit.Name}'.");
if (Write(agent, setting, value) is { } invalid)
return AdminResult.Failure(invalid);
host.AgentAdmin.PersistAgent(agent);
Audit(agentId, "unit_setting", new { unitId, setting = setting.ToString(), value }, reason);
written++;
}
if (defaultValue is not null)
RememberDefault(unit, setting, defaultValue);
Save(null, "unit_setting_done", new { unitId, setting = setting.ToString(), written, defaultValue }, reason);
return AdminResult.Success($"{written} agent(s) in '{unit.Name}' updated.");
}
/// <summary>
/// Gives an agent that is new to a unit the unit's defaults — only then. A move does not
/// apply them: it would overwrite exactly the values an experienced agent was tuned to.
/// </summary>
public void ApplyDefaultsToNewAgent(AgentConfig agent)
{
if (FindUnit(agent.UnitId) is not { } unit)
return;
var d = unit.Defaults;
if (!string.IsNullOrWhiteSpace(d.Model)) agent.Model = d.Model;
if (d.DailyCostUsd is { } cost) agent.Budget.DailyCostUsd = cost;
if (d.DailyTokens is { } tokens) agent.Budget.DailyTokens = tokens;
if (!string.IsNullOrWhiteSpace(d.WhenExhausted)) agent.Budget.WhenExhausted = d.WhenExhausted;
}
// ─── helpers ───
private StructurePreview Preview(
IReadOnlyList<string> agents, string? targetUnitId, (string UnitId, string? NewParent)? movingUnit,
List<string> problems, (string UnitId, IReadOnlyCollection<string>? Tools)? ceiling = null)
{
// The structure as it would be afterwards — the ceiling is computed on that.
var units = host.Instance.Units.Select(Clone).ToList();
if (movingUnit is { } m && units.FirstOrDefault(u => Same(u.Id, m.UnitId)) is { } moved)
moved.ParentId = m.NewParent;
if (ceiling is { } c && units.FirstOrDefault(u => Same(u.Id, c.UnitId)) is { } limited)
limited.ToolCeiling = c.Tools?.ToList();
var after = new OrgChart(units, host.Instance.Agents);
var lost = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var agentId in agents)
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
continue;
// Moving an agent: its new unit. Moving a unit or setting a ceiling: every agent
// stays in its unit — what changes is the structure above it.
var unitAfter = movingUnit is null && ceiling is null ? targetUnitId : agent.UnitId;
if (after.ToolCeiling(unitAfter) is { } limit)
{
var beyond = agent.Tools.Keys.Where(t => !limit.Contains(t)).ToList();
if (beyond.Count > 0)
lost[agentId] = beyond;
}
}
var running = (host.Engine?.Activity.ActiveRuns() ?? [])
.Select(r => r.AgentId)
.Where(id => agents.Contains(id, StringComparer.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return new StructurePreview(agents, lost, running, problems);
}
private static AdminResult? Refusal(StructurePreview preview, bool dropExcessTools)
{
if (preview.Problems.Count > 0)
return AdminResult.Failure(string.Join(" ", preview.Problems));
if (preview.Running.Count > 0)
return AdminResult.Failure(
$"Not while {string.Join(", ", preview.Running)} run(s): an agent must not get different rights in the middle of a run. Try again when the run is over.");
if (preview.ToolsLost.Count > 0 && !dropExcessTools)
return AdminResult.Failure(
"The new position has a tool ceiling these agents are beyond: " +
string.Join("; ", preview.ToolsLost.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value)}")) +
". Confirm to remove those tools, or raise the ceiling first.");
return null;
}
private void DropTools(StructurePreview preview)
{
foreach (var (agentId, tools) in preview.ToolsLost)
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
continue;
foreach (var tool in tools)
agent.Tools.Remove(tool);
host.AgentAdmin.PersistAgent(agent);
}
}
private static IEnumerable<UnitConfig> Subtree(OrgChart chart, string unitId)
{
if (chart.Unit(unitId) is not { } root)
yield break;
var stack = new Stack<UnitConfig>([root]);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (stack.Count > 0)
{
var unit = stack.Pop();
if (!seen.Add(unit.Id))
continue;
yield return unit;
foreach (var child in chart.Children(unit.Id))
stack.Push(child);
}
}
private static bool[] OutlierFlags(IReadOnlyList<decimal> values)
{
var flags = new bool[values.Count];
for (var i = 0; i < values.Count; i++)
{
var others = values.Where((_, j) => j != i && values[j] > 0).Order().ToList();
if (others.Count == 0 || values[i] <= 0)
continue;
var median = others[others.Count / 2];
flags[i] = values[i] >= median * OutlierFactor || values[i] <= median / OutlierFactor;
}
return flags;
}
private static decimal CurrentNumber(AgentConfig agent, UnitSetting setting) => setting switch
{
UnitSetting.DailyCostUsd => agent.Budget.DailyCostUsd,
UnitSetting.DailyTokens => agent.Budget.DailyTokens,
_ => 0
};
private static string CurrentText(AgentConfig agent, UnitSetting setting) => setting switch
{
UnitSetting.Model => agent.Model,
UnitSetting.WhenExhausted => agent.Budget.WhenExhausted,
_ => Format(CurrentNumber(agent, setting), setting)
};
private static string Format(decimal value, UnitSetting setting) => setting == UnitSetting.DailyTokens
? decimal.Round(value).ToString(System.Globalization.CultureInfo.InvariantCulture)
: decimal.Round(value, 2).ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
private static string? Write(AgentConfig agent, UnitSetting setting, string value)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
switch (setting)
{
case UnitSetting.DailyCostUsd when decimal.TryParse(value, System.Globalization.NumberStyles.Number, inv, out var cost) && cost >= 0:
agent.Budget.DailyCostUsd = cost;
return null;
case UnitSetting.DailyTokens when long.TryParse(value, System.Globalization.NumberStyles.Number, inv, out var tokens) && tokens >= 0:
agent.Budget.DailyTokens = tokens;
return null;
case UnitSetting.Model when !string.IsNullOrWhiteSpace(value):
agent.Model = value.Trim();
return null;
case UnitSetting.WhenExhausted when value.Trim() is "expire" or "catchUpOnce":
agent.Budget.WhenExhausted = value.Trim();
return null;
default:
return $"'{value}' is not a valid value for {setting}.";
}
}
private static void RememberDefault(UnitConfig unit, UnitSetting setting, string value)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
switch (setting)
{
case UnitSetting.DailyCostUsd: unit.Defaults.DailyCostUsd = decimal.Parse(value, inv); break;
case UnitSetting.DailyTokens: unit.Defaults.DailyTokens = long.Parse(value, inv); break;
case UnitSetting.Model: unit.Defaults.Model = value.Trim(); break;
case UnitSetting.WhenExhausted: unit.Defaults.WhenExhausted = value.Trim(); break;
}
}
private UnitConfig? FindUnit(string? unitId)
=> unitId is null ? null : host.Instance.Units.FirstOrDefault(u => Same(u.Id, unitId));
private string Describe(string? unitId)
=> FindUnit(unitId) is { } unit ? $"'{unit.Name}'" : "the top level";
private static UnitConfig Clone(UnitConfig u) => new()
{
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults
};
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private void Save(string? agentId, string action, object details, string reason)
{
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
Audit(agentId ?? "", action, details, reason);
}
private void Audit(string agentId, string action, object details, string reason)
{
if (host.AuditRepository is not { } audit)
return;
try
{
audit.AppendAsync(new AuditEntry
{
RunId = "",
AgentId = agentId,
Source = "admin",
Tool = "Organisation",
Arguments = JsonSerializer.Serialize(new { action, details }),
Status = AuditStatus.Ok,
Summary = string.IsNullOrWhiteSpace(reason) ? action : $"{action}: {reason.Trim()}",
OccurredAt = DateTime.UtcNow
}, CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
// The change itself is saved; a missing audit line must not undo it.
logger.LogError(ex, "Organisation change {Action} could not be written to the audit log", action);
}
}
}
@@ -21,6 +21,13 @@ public sealed class AgentConfig
[JsonPropertyName("unitSinceUtc")] [JsonPropertyName("unitSinceUtc")]
public DateTime? UnitSinceUtc { get; set; } public DateTime? UnitSinceUtc { get; set; }
/// <summary>
/// #683: The unit the agent left with its last move — so the lead it left is told as
/// well as the one it joined. <c>null</c> after the first placement.
/// </summary>
[JsonPropertyName("previousUnitId")]
public string? PreviousUnitId { get; set; }
[JsonPropertyName("model")] [JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5"; public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
@@ -73,6 +73,16 @@ public static class OrgBriefing
.ToList(); .ToList();
if (joined.Count > 0) if (joined.Count > 0)
sb.AppendLine("New in your unit: " + string.Join(", ", joined.Select(m => $"{m.AgentId} since {m.UnitSinceUtc:yyyy-MM-dd}")) + "."); sb.AppendLine("New in your unit: " + string.Join(", ", joined.Select(m => $"{m.AgentId} since {m.UnitSinceUtc:yyyy-MM-dd}")) + ".");
// The lead that was left is told as well as the one that was joined.
var left = org.Agents
.Where(a => string.Equals(a.PreviousUnitId, led.Id, StringComparison.OrdinalIgnoreCase)
&& a.UnitSinceUtc is { } s && nowUtc - s < RecentChange)
.ToList();
if (left.Count > 0)
sb.AppendLine("Left your unit recently: " + string.Join(", ", left.Select(a =>
$"{a.AgentId} (now in {(org.UnitOf(a.AgentId) is { } u ? u.Name : "the top level")})")) +
". Hand over what is still open with them.");
} }
var contacts = org.AddressBook(agentId) var contacts = org.AddressBook(agentId)
@@ -64,6 +64,8 @@ public sealed class OrgChart
public IReadOnlyCollection<UnitConfig> Units => _units.Values; public IReadOnlyCollection<UnitConfig> Units => _units.Values;
public IReadOnlyCollection<AgentConfig> Agents => _agents.Values;
public UnitConfig? Unit(string? unitId) public UnitConfig? Unit(string? unitId)
=> unitId is not null && _units.TryGetValue(unitId, out var unit) ? unit : null; => unitId is not null && _units.TryGetValue(unitId, out var unit) ? unit : null;
@@ -0,0 +1,252 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #683: Units in the administration — create, lead, move, limit, write unit-wide values.
/// </summary>
public sealed class OrgAdministrationTests : IDisposable
{
private readonly TestHost _test;
public OrgAdministrationTests()
{
_test = new TestHost(
Agent("marketing-lead", "WebFetch", "AgentComm", "Taskboard"),
Agent("ads", "WebFetch", "Taskboard"),
Agent("listing", "WebFetch", "Taskboard"),
Agent("specialist", "WebFetch", "Taskboard", "Database"),
Agent("bookkeeper", "Database", "Taskboard"));
}
public void Dispose() => _test.Dispose();
private static AgentConfig Agent(string id, params string[] tools)
=> new() { AgentId = id, DisplayName = id, Tools = tools.ToDictionary(t => t, _ => new Dictionary<string, object?>()) };
private OrgAdministration Org => _test.Host.Organisation;
private AgentConfig Find(string id) => _test.Host.AgentAdmin.Find(id)!;
private void BuildMarketing()
{
Org.CreateUnit("marketing", "Marketing", null, "Sell more.").Ok.ShouldBeTrue();
Org.CreateUnit("amazon", "Amazon", "marketing").Ok.ShouldBeTrue();
Org.MoveAgent("marketing-lead", "marketing", "setup").Ok.ShouldBeTrue();
Org.SetLeader("marketing", "marketing-lead").Ok.ShouldBeTrue();
foreach (var id in new[] { "ads", "listing", "specialist" })
Org.MoveAgent(id, "amazon", "setup").Ok.ShouldBeTrue();
}
// ═══════════════════ units ═══════════════════
[Fact]
public void A_unit_needs_a_valid_id_a_name_and_an_existing_parent()
{
Org.CreateUnit("bad id", "X", null).Ok.ShouldBeFalse();
Org.CreateUnit("x", " ", null).Ok.ShouldBeFalse();
Org.CreateUnit("x", "X", "nowhere").Ok.ShouldBeFalse();
Org.CreateUnit("x", "X", null).Ok.ShouldBeTrue();
Org.CreateUnit("x", "Again", null).Ok.ShouldBeFalse("ids are unique");
}
[Fact]
public void Only_a_member_can_lead_a_unit()
{
Org.CreateUnit("marketing", "Marketing", null);
Org.SetLeader("marketing", "ads").Ok.ShouldBeFalse();
Org.MoveAgent("ads", "marketing", "join");
Org.SetLeader("marketing", "ads").Ok.ShouldBeTrue();
}
[Fact]
public void Only_an_empty_unit_can_be_deleted()
{
BuildMarketing();
Org.DeleteUnit("amazon").Ok.ShouldBeFalse("it has members");
foreach (var id in new[] { "ads", "listing", "specialist" })
Org.MoveAgent(id, null, "leave");
Org.DeleteUnit("amazon").Ok.ShouldBeTrue();
}
// ═══════════════════ moves ═══════════════════
[Fact]
public void A_move_records_since_when_and_where_from()
{
BuildMarketing();
Org.MoveAgent("ads", "marketing", "promoted").Ok.ShouldBeTrue();
var ads = Find("ads");
ads.UnitId.ShouldBe("marketing");
ads.PreviousUnitId.ShouldBe("amazon");
ads.UnitSinceUtc.ShouldNotBeNull();
}
[Fact]
public void A_lead_that_moves_leaves_its_unit_without_lead()
{
BuildMarketing();
Org.MoveAgent("marketing-lead", "amazon", "reorg").Ok.ShouldBeTrue();
Org.Current().LeaderOf("marketing").ShouldBeNull();
}
[Fact]
public void A_unit_cannot_be_moved_under_itself()
{
BuildMarketing();
Org.MoveUnit("marketing", "amazon", "oops").Ok.ShouldBeFalse();
Org.MoveUnit("marketing", "marketing", "oops").Ok.ShouldBeFalse();
}
[Fact]
public void A_moved_unit_takes_its_members_along_and_tells_them()
{
BuildMarketing();
Org.CreateUnit("marketplaces", "Marketplaces", "marketing");
var before = Find("ads").UnitSinceUtc;
Org.MoveUnit("amazon", "marketplaces", "a new level in between").Ok.ShouldBeTrue();
Find("ads").UnitId.ShouldBe("amazon", "the unit is the same");
Org.Current().PathOf("amazon").ShouldBe("Marketing / Marketplaces / Amazon");
Find("ads").UnitSinceUtc.ShouldNotBe(before, "the position changed, so the briefing says so");
}
// ═══════════════════ tool ceiling ═══════════════════
[Fact]
public void A_move_under_a_lower_ceiling_is_refused_until_confirmed()
{
BuildMarketing();
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "no databases in ads").Ok.ShouldBeFalse(
"the specialist has Database — that has to be confirmed via the preview first");
var preview = Org.PreviewToolCeiling("amazon", ["WebFetch", "Taskboard"]);
preview.ToolsLost.Keys.ShouldBe(["specialist"]);
preview.ToolsLost["specialist"].ShouldBe(["Database"]);
}
[Fact]
public void Lowering_a_ceiling_removes_the_tools_beyond_it_from_the_members()
{
BuildMarketing();
Org.MoveAgent("specialist", null, "out first").Ok.ShouldBeTrue();
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "no databases in ads").Ok.ShouldBeTrue();
// And from now on it cannot be granted.
var refused = _test.Host.AgentAdmin.SetToolEnabled("ads", "Database", true);
refused.Ok.ShouldBeFalse();
refused.Message.ShouldContain("ceiling");
}
[Fact]
public void Moving_into_a_ceiling_needs_the_tools_beyond_it_dropped_explicitly()
{
Org.CreateUnit("amazon", "Amazon", null);
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "setup").Ok.ShouldBeTrue();
var refused = Org.MoveAgent("specialist", "amazon", "join");
refused.Ok.ShouldBeFalse();
refused.Message.ShouldContain("Database");
Find("specialist").UnitId.ShouldBeNull("nothing happened");
Org.MoveAgent("specialist", "amazon", "join", dropExcessTools: true).Ok.ShouldBeTrue();
Find("specialist").Tools.Keys.ShouldNotContain("Database");
}
[Fact]
public void A_ceiling_higher_up_limits_the_units_below()
{
BuildMarketing();
Org.MoveAgent("specialist", null, "out first");
Org.SetToolCeiling("marketing", ["WebFetch", "Taskboard", "AgentComm"], "setup").Ok.ShouldBeTrue();
_test.Host.AgentAdmin.SetToolEnabled("ads", "Database", true).Ok.ShouldBeFalse(
"amazon has no ceiling of its own, but marketing above it has");
}
// ═══════════════════ unit-wide values ═══════════════════
[Fact]
public void A_unit_wide_budget_marks_the_specialist_as_an_outlier_and_leaves_it_unselected()
{
BuildMarketing();
Find("ads").Budget.DailyCostUsd = 5;
Find("listing").Budget.DailyCostUsd = 4;
Find("specialist").Budget.DailyCostUsd = 40; // deliberately large
Find("marketing-lead").Budget.DailyCostUsd = 6;
var preview = Org.PreviewSetting("marketing", UnitSetting.DailyCostUsd, "10");
preview.Count.ShouldBe(4, "the unit and its sub-units");
var specialist = preview.Single(c => c.AgentId == "specialist");
specialist.Outlier.ShouldBeTrue();
specialist.SelectedByDefault.ShouldBeFalse();
preview.Where(c => c.AgentId != "specialist").ShouldAllBe(c => c.SelectedByDefault && c.Proposed == "10.00");
}
[Fact]
public void A_distributed_total_keeps_the_proportions()
{
BuildMarketing();
Find("ads").Budget.DailyCostUsd = 10;
Find("listing").Budget.DailyCostUsd = 10;
Find("specialist").Budget.DailyCostUsd = 20;
Find("marketing-lead").Budget.DailyCostUsd = 0;
var preview = Org.PreviewSetting("amazon", UnitSetting.DailyCostUsd, "80", UnitSettingMode.Distribute);
preview.Single(c => c.AgentId == "specialist").Proposed.ShouldBe("40.00");
preview.Single(c => c.AgentId == "ads").Proposed.ShouldBe("20.00");
}
[Fact]
public void Applying_writes_only_the_chosen_agents_and_remembers_the_default()
{
BuildMarketing();
Find("specialist").Budget.DailyCostUsd = 40;
var result = Org.ApplySetting("amazon", UnitSetting.DailyCostUsd,
new Dictionary<string, string> { ["ads"] = "10", ["listing"] = "10" }, "budget round", defaultValue: "10");
result.Ok.ShouldBeTrue(result.Message);
Find("ads").Budget.DailyCostUsd.ShouldBe(10);
Find("specialist").Budget.DailyCostUsd.ShouldBe(40, "not chosen, not touched");
Org.Current().Unit("amazon")!.Defaults.DailyCostUsd.ShouldBe(10);
}
[Fact]
public void An_agent_outside_the_unit_cannot_be_written_through_it()
{
BuildMarketing();
Org.ApplySetting("amazon", UnitSetting.DailyCostUsd,
new Dictionary<string, string> { ["bookkeeper"] = "1" }, "sneaky").Ok.ShouldBeFalse();
}
[Fact]
public void A_new_agent_placed_in_a_unit_starts_with_its_defaults_but_a_moved_one_keeps_its_values()
{
BuildMarketing();
Org.ApplySetting("amazon", UnitSetting.DailyCostUsd, new Dictionary<string, string>(), "defaults", defaultValue: "7");
Find("bookkeeper").Budget.DailyCostUsd = 30;
Org.MoveAgent("bookkeeper", "amazon", "moved").Ok.ShouldBeTrue();
Find("bookkeeper").Budget.DailyCostUsd.ShouldBe(30, "a move does not overwrite tuned values");
var created = _test.Host.AgentAdmin.Add(new NewAgent("newbie", "Newbie", "test/model", UnitId: "amazon"));
created.Ok.ShouldBeTrue(created.Message);
Find("newbie").Budget.DailyCostUsd.ShouldBe(7);
Find("newbie").UnitId.ShouldBe("amazon");
}
}