using System.Text.Json; using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Organisation; using Microsoft.Extensions.Logging; namespace ClawdDotNet.App.Services; /// A unit-wide value that can be written into the members (#683). public enum UnitSetting { DailyCostUsd, DailyTokens, Model, WhenExhausted } /// How a unit-wide number is turned into per-agent values. public enum UnitSettingMode { /// Every agent gets the value ("each gets 10 $"). PerAgent, /// The value is a total, split in proportion to the current values ("the unit gets 100 $"). Distribute } /// One line of a preview: what would change at one agent. public sealed record UnitSettingChange( string AgentId, string DisplayName, string Current, string Proposed, bool Outlier, bool SelectedByDefault); /// What a move or a lowered ceiling would do before it is done. public sealed record StructurePreview( IReadOnlyList AffectedAgents, IReadOnlyDictionary> ToolsLost, IReadOnlyList Running, IReadOnlyList Problems) { public bool CanApply => Running.Count == 0 && Problems.Count == 0; } /// /// #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 ). /// /// Three rules from the concept (section 3): /// /// No value overrides another at run time. 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. /// The tool ceiling is checked when writing. A tool beyond it cannot be /// granted; lowering it removes the tools from the agents, after a preview. /// A move never happens under a running agent. 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. /// /// /// Every structural change is written to the audit log with its reason — a change of /// structure is a change of rights (#205, #641). /// public sealed class OrgAdministration(AppHost host, ILogger logger) { /// A value this far from the median of the others is an outlier. 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."); } /// Only an empty unit can go — members and sub-units have to be moved first. 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."); } /// The lead must be a member of the unit. null leaves it without a lead. 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(); 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); } /// /// Moves an agent. Tools beyond the new unit's ceiling are removed only with /// — otherwise the move is refused and says which. /// A lead leaving its unit leaves it without a lead. /// 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(); 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); } /// /// 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. /// 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? tools) { var chart = Chart; var problems = new List(); 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)); } /// /// Sets the ceiling. Lowering it removes the tools beyond it from the members — shown by /// first, and only with , /// like a move. null lifts the ceiling. /// public AdminResult SetToolCeiling( string unitId, IReadOnlyCollection? 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)}."); } /// /// Whether an agent may be granted a tool — called by /// when writing. null means yes; otherwise the reason. /// 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 ─── /// /// 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. /// public IReadOnlyList 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 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(); } /// /// Writes the values chosen from a preview into the agents, one audit entry each, and — /// with — keeps the value as the unit's default for /// agents that join. /// public AdminResult ApplySetting( string unitId, UnitSetting setting, IReadOnlyDictionary 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."); } /// /// 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. /// 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 agents, string? targetUnitId, (string UnitId, string? NewParent)? movingUnit, List problems, (string UnitId, IReadOnlyCollection? 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>(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 Subtree(OrgChart chart, string unitId) { if (chart.Unit(unitId) is not { } root) yield break; var stack = new Stack([root]); var seen = new HashSet(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 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); } } }