diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 1139ee5..ee6d2c1 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -214,6 +214,9 @@ public sealed class AppHost : IAsyncDisposable /// Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen. public AgentAdministration AgentAdmin => _agentAdmin.Value; + /// #683: Units, moves, tool ceilings, unit-wide values. Stateless. + public OrgAdministration Organisation => new(this, LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation")); + /// Dienste der Instanz anlegen, entfernen, umschalten. public ServiceAdministration ServiceAdmin => _serviceAdmin.Value; diff --git a/src/ClawdDotNet.App/Services/AgentAdministration.cs b/src/ClawdDotNet.App/Services/AgentAdministration.cs index 877457f..905f07e 100644 --- a/src/ClawdDotNet.App/Services/AgentAdministration.cs +++ b/src/ClawdDotNet.App/Services/AgentAdministration.cs @@ -1,6 +1,7 @@ using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Instances; using ClawdDotNet.Core.Staging; +using Microsoft.Extensions.Logging; namespace ClawdDotNet.App.Services; @@ -9,7 +10,8 @@ public sealed record NewAgent( string AgentId, string DisplayName, string Model, - string Description = ""); + string Description = "", + string? UnitId = null); /// /// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen. @@ -86,6 +88,10 @@ public sealed class AgentAdministration(AppHost host) if (Find(agentId) is not null) 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; try @@ -111,6 +117,14 @@ public sealed class AgentAdministration(AppHost host) config.Model = request.Model; 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.Instance.Agents.Add(config); @@ -181,6 +195,10 @@ public sealed class AgentAdministration(AppHost host) 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)) agent.Tools[toolName] = new Dictionary(); } @@ -278,6 +296,10 @@ public sealed class AgentAdministration(AppHost host) if (Find(agentId) is not { } agent) 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 // Woerterbuch. Ohne diese Uebernahme loeschte jedes Speichern still "erlaubt" und // "verboten" - und damit die Rechte des Agenten. @@ -309,6 +331,22 @@ public sealed class AgentAdministration(AppHost host) /// 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) PersistAgent(agent); @@ -329,7 +367,7 @@ public sealed class AgentAdministration(AppHost host) /// werden sie gelesen, und was in AgentSettings.json danebenstünde, überschriebe /// der nächste Ladevorgang ohnehin. /// - private void PersistAgent(AgentConfig agent) + internal void PersistAgent(AgentConfig agent) { // Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten // Instanzdatei. Ihm hier einen Ordner anzulegen waere eine Nebenwirkung des diff --git a/src/ClawdDotNet.App/Services/OrgAdministration.cs b/src/ClawdDotNet.App/Services/OrgAdministration.cs new file mode 100644 index 0000000..7dd0f84 --- /dev/null +++ b/src/ClawdDotNet.App/Services/OrgAdministration.cs @@ -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; + +/// 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); + } + } +} diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs index 39b2039..9c2d06a 100644 --- a/src/ClawdDotNet.Core/Config/AgentConfig.cs +++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs @@ -21,6 +21,13 @@ public sealed class AgentConfig [JsonPropertyName("unitSinceUtc")] public DateTime? UnitSinceUtc { get; set; } + /// + /// #683: The unit the agent left with its last move — so the lead it left is told as + /// well as the one it joined. null after the first placement. + /// + [JsonPropertyName("previousUnitId")] + public string? PreviousUnitId { get; set; } + [JsonPropertyName("model")] public string Model { get; set; } = "anthropic/claude-sonnet-4-5"; diff --git a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs index 5eda4f2..33fe1cb 100644 --- a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs +++ b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs @@ -73,6 +73,16 @@ public static class OrgBriefing .ToList(); if (joined.Count > 0) 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) diff --git a/src/ClawdDotNet.Core/Organisation/OrgChart.cs b/src/ClawdDotNet.Core/Organisation/OrgChart.cs index b11693e..afc3060 100644 --- a/src/ClawdDotNet.Core/Organisation/OrgChart.cs +++ b/src/ClawdDotNet.Core/Organisation/OrgChart.cs @@ -64,6 +64,8 @@ public sealed class OrgChart public IReadOnlyCollection Units => _units.Values; + public IReadOnlyCollection Agents => _agents.Values; + public UnitConfig? Unit(string? unitId) => unitId is not null && _units.TryGetValue(unitId, out var unit) ? unit : null; diff --git a/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs new file mode 100644 index 0000000..2b4eea4 --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Services/OrgAdministrationTests.cs @@ -0,0 +1,252 @@ +using ClawdDotNet.App.Services; +using ClawdDotNet.Core.Config; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Services; + +/// +/// #683: Units in the administration — create, lead, move, limit, write unit-wide values. +/// +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()) }; + + 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 { ["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 { ["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(), "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"); + } +}