diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 1cd4d9a..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; @@ -565,6 +568,12 @@ public sealed class AppHost : IAsyncDisposable return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir; }); + // #683: The organisation tree, fresh from the configuration on every question, so a + // change made through the administration applies to the next run without a restart. + engine.OrganisationProvider = () => Core.Organisation.OrgChart.From(instance); + foreach (var problem in Core.Organisation.OrgChart.From(instance).Problems()) + logger.LogWarning("Organisation: {Problem}", problem); + engine.LoadPersistedChats(); var (scanner, staging, board) = BuildTaskboard( 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 1bbbd92..9c2d06a 100644 --- a/src/ClawdDotNet.Core/Config/AgentConfig.cs +++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs @@ -10,6 +10,24 @@ public sealed class AgentConfig [JsonPropertyName("displayName")] public string DisplayName { get; set; } = ""; + /// #683: The unit this agent belongs to; null for the top level. + [JsonPropertyName("unitId")] + public string? UnitId { get; set; } + + /// + /// #683: Since when the agent is in its unit (UTC) — so it can be told that it moved, + /// and its lead that someone joined. + /// + [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/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs index 20dfa4a..054dbbd 100644 --- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs +++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs @@ -172,6 +172,13 @@ public sealed class InstanceConfig [JsonPropertyName("agents")] public List Agents { get; set; } = new(); + /// + /// #683: Departments, teams, groups — the organisation tree. Empty means no structure: + /// every agent can reach every other, as before units existed. + /// + [JsonPropertyName("units")] + public List Units { get; set; } = new(); + [JsonPropertyName("services")] public List Services { get; set; } = new(); } diff --git a/src/ClawdDotNet.Core/Config/UnitConfig.cs b/src/ClawdDotNet.Core/Config/UnitConfig.cs new file mode 100644 index 0000000..0d68c2b --- /dev/null +++ b/src/ClawdDotNet.Core/Config/UnitConfig.cs @@ -0,0 +1,72 @@ +using System.Text.Json.Serialization; + +namespace ClawdDotNet.Core.Config; + +/// +/// #683: A unit — department, team, group — as a node in the organisation tree. +/// +/// Stored in the instance configuration, next to the agents. An agent's tools +/// already live there; a unit's rules belong beside them, so the truth about an agent is in +/// one place. Every structural change is written to the audit log by the administration, +/// which is what the concept asked the database for. +/// +/// No value here overrides an agent at run time (Organisation-Konzept, section +/// 3). are only the starting values for agents that join; +/// is checked when an agent's tools are written, never laid over +/// them while it runs. What stands at the agent is what applies. +/// +public sealed class UnitConfig +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// The parent unit; null for a top-level unit. + [JsonPropertyName("parentId")] + public string? ParentId { get; set; } + + /// + /// The agent leading this unit — one of its members. May be empty: then the nearest + /// unit above that has a lead takes over. + /// + [JsonPropertyName("leaderAgentId")] + public string? LeaderAgentId { get; set; } + + /// What the unit exists for — the top of every task chain in it (#714). + [JsonPropertyName("goal")] + public string Goal { get; set; } = ""; + + /// + /// The tools members of this unit may have at most; null means no ceiling. A + /// ceiling applies to sub-units as well — the effective ceiling is the intersection + /// along the path to the root. + /// + [JsonPropertyName("toolCeiling")] + public List? ToolCeiling { get; set; } + + /// Starting values for agents that join this unit. + [JsonPropertyName("defaults")] + public UnitDefaults Defaults { get; set; } = new(); +} + +/// +/// #683: What an agent gets when it joins a unit. null leaves the agent's own value. +/// Changing these does not touch existing members — writing a value into them is a +/// separate, previewed step. +/// +public sealed class UnitDefaults +{ + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("dailyCostUsd")] + public decimal? DailyCostUsd { get; set; } + + [JsonPropertyName("dailyTokens")] + public long? DailyTokens { get; set; } + + [JsonPropertyName("whenExhausted")] + public string? WhenExhausted { get; set; } +} diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 496f81a..fb378cf 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -44,6 +44,21 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut /// #711: Who is working right now and who reached whom — for the team overview. public Activity.AgentActivity Activity { get; } = new(); + /// + /// #683: The organisation tree, built fresh from the configuration on each call. Without + /// it, the instance has no structure and every agent reaches every other, as before. + /// + public Func? OrganisationProvider { get; set; } + + private Organisation.OrgChart CurrentOrganisation() + => OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []); + + /// The agent's system prompt with its organisation section (#683). + private string SystemPromptFor(AgentConfig agentConfig, bool hasTools) + => Organisation.OrgBriefing.WithSection( + UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, hasTools), + Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime)); + /// /// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8). /// A→B ist die Tiefe 1, A→B→C die Tiefe 2. @@ -226,7 +241,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut var messages = new List(); // #193: Die Regel zu gerahmten Werkzeugergebnissen gehoert zum festen Teil. - var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0); + // #683: dazu der Abschnitt zur Organisation. + var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0); if (!string.IsNullOrWhiteSpace(systemPrompt)) messages.Add(ChatMessage.System(systemPrompt)); @@ -556,11 +572,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut messages = new List(); // #193 - siehe RunCoreAsync. Bestehende Verlaeufe behalten ihren // Systemprompt; die Regel kommt mit dem naechsten neuen Kontext. - var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0); + var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0); if (!string.IsNullOrWhiteSpace(systemPrompt)) messages.Add(ChatMessage.System(systemPrompt)); _chatContexts[agentConfig.AgentId] = messages; } + else if (messages.Count > 0 && messages[0].Role == "system") + { + // #683: Nur der Organisationsabschnitt folgt der Struktur — nach einem + // Umzug weiss der Agent sonst im laufenden Chat nichts davon. Der Rest + // des bestehenden Systemprompts bleibt, wie er war (#193). + var section = Organisation.OrgBriefing.For( + CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime); + var refreshed = Organisation.OrgBriefing.WithSection(messages[0].Content ?? "", section); + if (refreshed != messages[0].Content) + messages[0] = ChatMessage.System(refreshed); + } } // Routing-Hinweis: Dem Agenten mitteilen, woher die Nachricht kommt @@ -1052,19 +1079,41 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut $"Agent '{toAgentId}' wartet in dieser Kette bereits auf eine Antwort und " + "kann keinen zweiten Auftrag annehmen, ohne sich selbst zu blockieren."; - if (callerChain.Depth >= MaxDelegationDepth) + // #683: Die Grenze folgt der Tiefe des Baums; der eingestellte Wert bleibt Untergrenze. + var maxDepth = CurrentOrganisation().DelegationDepth(MaxDelegationDepth); + if (callerChain.Depth >= maxDepth) return $"Delegationstiefe erschöpft: {callerChain} hat die Grenze von " + - $"{MaxDelegationDepth} Weitergaben erreicht. Erledige den Rest selbst " + + $"{maxDepth} Weitergaben erreicht. Erledige den Rest selbst " + "oder gib die Aufgabe an den Auftraggeber zurück."; return null; } + /// + /// #683: The address book, enforced — not merely described in the prompt. A refused + /// contact is an answer to the caller, naming the way that is open: its lead. + /// + private string? CheckAddressBook(string fromAgentId, string toAgentId) + { + var org = CurrentOrganisation(); + if (org.CanAddress(fromAgentId, toAgentId)) + return null; + + var lead = org.EffectiveLeaderOf(fromAgentId); + return $"'{toAgentId}' is not in your address book: from your position in the organisation you " + + "cannot reach this agent directly. " + + (lead is not null + ? $"Hand the matter to your lead '{lead.AgentId}', who can pass it on." + : "Create a task for @human if it needs someone outside your reach."); + } + public IReadOnlyList ListAgents(string callerAgentId) { var configs = _agentConfigProvider?.Invoke() ?? []; + // #683: Only the agent's address book — the cut of the organisation. + var reachable = CurrentOrganisation().AddressBook(callerAgentId); return configs - .Where(a => a.AgentId != callerAgentId) + .Where(a => a.AgentId != callerAgentId && reachable.Contains(a.AgentId)) .Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId))) .ToList(); } @@ -1097,6 +1146,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut if (targetConfig is null) return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found."); + if (CheckAddressBook(fromAgentId, toAgentId) is { } outOfReach) + return new AgentMessageResult(false, null, outOfReach); + if (CheckDelegation(chain, fromAgentId, toAgentId) is { } refused) return new AgentMessageResult(false, null, refused); @@ -1149,6 +1201,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut // Auch der Spawn zählt: Er startet einen vollen Lauf und kann seinerseits // delegieren. Ohne diese Prüfung wäre die Kette über spawn zu umgehen. + if (CheckAddressBook(fromAgentId, targetAgentId) is { } outOfReach) + return new AgentSpawnResult(false, targetAgentId, null, outOfReach); + if (CheckDelegation(chain, fromAgentId, targetAgentId) is { } refused) return new AgentSpawnResult(false, targetAgentId, null, refused); @@ -1606,7 +1661,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut this, _memoryRepository, _taskRepository, - chain ?? DelegationChain.Root(agentConfig.AgentId)); + chain ?? DelegationChain.Root(agentConfig.AgentId)) + { + Organisation = CurrentOrganisation() + }; } /// diff --git a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs new file mode 100644 index 0000000..33fe1cb --- /dev/null +++ b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs @@ -0,0 +1,134 @@ +using System.Text; + +namespace ClawdDotNet.Core.Organisation; + +/// +/// #683: The "Organisation" section of an agent's system prompt — its unit, its lead, whom it +/// can reach, and what changed recently. +/// +/// Why the code writes it and not the agent's own texts. The concept demands +/// that agents are told when they move, and warns: an agent that has to infer its lead from +/// a prompt escalates to the wrong place. A section generated from the tree is always true — +/// a move changes it by itself, for the agent that moved and for both leads. +/// +/// Without units there is no section, so instances without a structure keep +/// exactly the prompt they had — and the prompt cache that goes with it. +/// +public static class OrgBriefing +{ + /// Starts the section; also how an existing chat context finds it to replace it. + public const string Heading = "# Organisation"; + + /// How long a move or a new colleague is pointed out. + public static readonly TimeSpan RecentChange = TimeSpan.FromDays(7); + + private const int MaxDescription = 120; + + public static string? For(OrgChart org, string agentId, DateTime nowUtc) + { + if (!org.HasUnits || org.Agent(agentId) is not { } agent) + return null; + + var sb = new StringBuilder(); + sb.AppendLine(Heading); + + var unit = org.UnitOf(agentId); + if (unit is null) + { + sb.AppendLine("You are on the top level of this instance, above all units."); + } + else + { + sb.Append($"You belong to the unit \"{unit.Name}\" ({org.PathOf(unit.Id)})"); + if (agent.UnitSinceUtc is { } since) + { + sb.Append($", since {since:yyyy-MM-dd}"); + if (nowUtc - since < RecentChange) + sb.Append(" — this is recent: your lead, your colleagues and possibly your tools changed"); + } + sb.AppendLine("."); + + if (!string.IsNullOrWhiteSpace(unit.Goal)) + sb.AppendLine($"Goal of your unit: {unit.Goal.Trim()}"); + } + + var lead = org.EffectiveLeaderOf(agentId); + if (lead is not null) + sb.AppendLine($"Your lead: {Name(lead)}. Questions, problems and anything outside your unit go to your lead."); + else + sb.AppendLine("There is no lead agent above you. What you cannot decide goes to a human: create a task for @human."); + + if (org.LedBy(agentId) is { } led) + { + sb.AppendLine($"You lead the unit \"{led.Name}\". Delegate within it, and hand what goes beyond it to your own lead."); + var subUnits = org.Children(led.Id); + if (subUnits.Count > 0) + { + sb.AppendLine("Its sub-units: " + string.Join("; ", subUnits.Select(u => + org.LeaderOf(u.Id) is { } l ? $"{u.Name} (lead: {l.AgentId})" : $"{u.Name} (no lead — its members report to you)")) + "."); + } + + var joined = org.MembersOf(led.Id) + .Where(m => m.AgentId != agentId && m.UnitSinceUtc is { } s && nowUtc - s < RecentChange) + .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) + .Where(id => lead is null || !string.Equals(id, lead.AgentId, StringComparison.OrdinalIgnoreCase)) + .Select(id => org.Agent(id)!) + .OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (contacts.Count > 0) + { + sb.AppendLine("You can also reach:"); + foreach (var contact in contacts) + sb.AppendLine($"- {Name(contact)}{Describe(contact.Description)}"); + } + + sb.Append("Agents not named here cannot be reached from your position — go through your lead."); + return sb.ToString(); + } + + /// + /// Replaces the organisation section of a system prompt, appends it, or removes it — + /// leaving everything before it untouched. Existing chat contexts keep their system + /// prompt otherwise (#193); only this section follows the structure. + /// + public static string WithSection(string systemPrompt, string? section) + { + var start = systemPrompt.IndexOf("\n\n" + Heading + "\n", StringComparison.Ordinal); + var basePrompt = start >= 0 ? systemPrompt[..start] : systemPrompt; + if (start < 0 && systemPrompt.StartsWith(Heading + "\n", StringComparison.Ordinal)) + basePrompt = ""; + + if (string.IsNullOrWhiteSpace(section)) + return basePrompt; + return basePrompt.Length == 0 ? section : basePrompt + "\n\n" + section; + } + + private static string Name(Config.AgentConfig agent) + => string.IsNullOrWhiteSpace(agent.DisplayName) || agent.DisplayName == agent.AgentId + ? agent.AgentId + : $"{agent.DisplayName} ({agent.AgentId})"; + + private static string Describe(string? description) + { + if (string.IsNullOrWhiteSpace(description)) + return ""; + var line = description.ReplaceLineEndings(" ").Trim(); + return " — " + (line.Length > MaxDescription ? line[..MaxDescription] + "…" : line); + } +} diff --git a/src/ClawdDotNet.Core/Organisation/OrgChart.cs b/src/ClawdDotNet.Core/Organisation/OrgChart.cs new file mode 100644 index 0000000..afc3060 --- /dev/null +++ b/src/ClawdDotNet.Core/Organisation/OrgChart.cs @@ -0,0 +1,324 @@ +using ClawdDotNet.Core.Config; + +namespace ClawdDotNet.Core.Organisation; + +/// +/// #683: The organisation tree of an instance — units, members, leads — and everything that +/// follows from it: who an agent can reach, who its lead is, how deep delegation may go, +/// which tools a unit's members may have at most. +/// +/// One rule holds it together: an agent knows only its unit and its lead. +/// The cut of the address book is the organisation (Organisation-Konzept, section 3). It +/// is computed from the tree, never configured, so it cannot be widened by accident — +/// whoever wants an agent to reach further moves it in the tree, visibly. +/// +/// Pure. Built from the configuration, no I/O, no state. Every question is +/// answered from the snapshot it was built from, so the engine, the administration and +/// the tests all see the same organisation. +/// +/// Without units nothing changes: every agent reaches every other, as before. +/// +public sealed class OrgChart +{ + private readonly Dictionary _units; + private readonly Dictionary _agents; + private readonly Dictionary> _children; + private readonly Dictionary> _members; + + /// The key under which top-level units and agents without a unit are grouped. + private const string Root = ""; + + public OrgChart(IEnumerable units, IEnumerable agents) + { + _units = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var unit in units) + _units.TryAdd(unit.Id, unit); + + _agents = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var agent in agents) + _agents.TryAdd(agent.AgentId, agent); + + _children = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var unit in _units.Values) + { + var parent = KnownParent(unit) ?? Root; + if (!_children.TryGetValue(parent, out var list)) + _children[parent] = list = []; + list.Add(unit); + } + + _members = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var agent in _agents.Values) + { + var key = KnownUnit(agent) ?? Root; + if (!_members.TryGetValue(key, out var list)) + _members[key] = list = []; + list.Add(agent); + } + } + + public static OrgChart From(InstanceConfig instance) => new(instance.Units, instance.Agents); + + /// Whether the instance has any structure at all. + public bool HasUnits => _units.Count > 0; + + 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; + + public AgentConfig? Agent(string agentId) => _agents.GetValueOrDefault(agentId); + + /// The unit an agent belongs to; null for the top level (or an unknown unit). + public UnitConfig? UnitOf(string agentId) + => _agents.TryGetValue(agentId, out var agent) ? Unit(KnownUnit(agent)) : null; + + /// Direct sub-units; null asks for the top-level units. + public IReadOnlyList Children(string? unitId) + => _children.TryGetValue(unitId ?? Root, out var list) ? list : []; + + /// Direct members; null asks for the agents on the top level. + public IReadOnlyList MembersOf(string? unitId) + => _members.TryGetValue(unitId ?? Root, out var list) ? list : []; + + /// The units above, nearest first — not including the unit itself. + public IReadOnlyList Ancestors(string unitId) + { + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { unitId }; + var current = Unit(unitId); + while (current is not null && KnownParent(current) is { } parentId && seen.Add(parentId)) + { + current = _units[parentId]; + result.Add(current); + } + return result; + } + + /// "Marketing / Marketplaces / Amazon". + public string PathOf(string unitId) + { + if (Unit(unitId) is not { } unit) + return ""; + var names = Ancestors(unitId).Select(u => u.Name).Reverse().Append(unit.Name); + return string.Join(" / ", names); + } + + /// Depth of a unit: a top-level unit is 1. + public int Depth(string unitId) => Unit(unitId) is null ? 0 : Ancestors(unitId).Count + 1; + + /// The deepest unit's depth; 0 without units. + public int TreeDepth => _units.Keys.Select(Depth).DefaultIfEmpty(0).Max(); + + /// + /// How many hand-overs a delegation chain may have: enough to go from the top to the + /// bottom of the tree and one step further, and never less than the configured floor. + /// Before #683 this was a fixed 3 — lead, department, team, agent already used it up. + /// + public int DelegationDepth(int configuredFloor) => Math.Max(configuredFloor, TreeDepth + 1); + + /// The lead of a unit, if it has one that is actually a member. + public AgentConfig? LeaderOf(string? unitId) + { + if (Unit(unitId) is not { LeaderAgentId: { Length: > 0 } leaderId } unit) + return null; + return _agents.TryGetValue(leaderId, out var leader) + && string.Equals(KnownUnit(leader), unit.Id, StringComparison.OrdinalIgnoreCase) + ? leader + : null; + } + + /// The unit this agent leads, if any. + public UnitConfig? LedBy(string agentId) + { + var unit = UnitOf(agentId); + return unit is not null && LeaderOf(unit.Id) is { } leader + && string.Equals(leader.AgentId, agentId, StringComparison.OrdinalIgnoreCase) + ? unit + : null; + } + + /// + /// Who the agent answers to: the lead of its unit — or, if it leads that unit itself or + /// the unit has no lead, the nearest lead above. null means: the human. + /// + public AgentConfig? EffectiveLeaderOf(string agentId) + { + var unit = UnitOf(agentId); + if (unit is null) + return null; + + foreach (var candidate in new[] { unit }.Concat(Ancestors(unit.Id))) + { + if (LeaderOf(candidate.Id) is { } leader + && !string.Equals(leader.AgentId, agentId, StringComparison.OrdinalIgnoreCase)) + return leader; + } + return null; + } + + /// + /// Everyone the agent may address — the cut of the address book (section 3): + /// + /// the members of its own unit and its effective lead; + /// as a lead: the leads of its sub-units — and, where a sub-unit has no lead, that + /// sub-unit's members, because this lead is who they answer to — and the leads of its + /// sibling units; + /// on the top level: each other and the leads of the top-level units, both ways. + /// + /// Without units: everyone. + /// + public IReadOnlySet AddressBook(string agentId) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!_agents.ContainsKey(agentId)) + return result; + + if (!HasUnits) + { + result.UnionWith(_agents.Keys); + result.Remove(agentId); + return result; + } + + var unit = UnitOf(agentId); + foreach (var member in MembersOf(unit?.Id)) + result.Add(member.AgentId); + + if (EffectiveLeaderOf(agentId) is { } lead) + result.Add(lead.AgentId); + + if (unit is null) + { + // Top level: the house management reaches the heads of the top-level units. + AddLeadsBelow(null, result); + } + else if (LedBy(agentId) is { } led) + { + AddLeadsBelow(led.Id, result); + + foreach (var sibling in Children(KnownParent(led))) + { + if (!string.Equals(sibling.Id, led.Id, StringComparison.OrdinalIgnoreCase) + && LeaderOf(sibling.Id) is { } siblingLead) + result.Add(siblingLead.AgentId); + } + + // A top-level lead reaches the top level — the same line, seen from below. + if (KnownParent(led) is null) + foreach (var member in MembersOf(null)) + result.Add(member.AgentId); + } + + result.Remove(agentId); + return result; + } + + public bool CanAddress(string fromAgentId, string toAgentId) + => AddressBook(fromAgentId).Contains(toAgentId); + + /// + /// The tools a member of the unit may have at most: the intersection of the ceilings + /// along the path to the top. null means no ceiling anywhere on the path. + /// + public IReadOnlySet? ToolCeiling(string? unitId) + { + if (Unit(unitId) is not { } unit) + return null; + + HashSet? ceiling = null; + foreach (var node in new[] { unit }.Concat(Ancestors(unit.Id))) + { + if (node.ToolCeiling is null) + continue; + if (ceiling is null) + ceiling = new HashSet(node.ToolCeiling, StringComparer.OrdinalIgnoreCase); + else + ceiling.IntersectWith(node.ToolCeiling); + } + return ceiling; + } + + /// + /// What is wrong with the structure — empty when it is sound. Checked when the + /// administration writes and when an instance starts; the tree is still usable with + /// problems (unknown references count as top level), but they are named. + /// + public IReadOnlyList Problems() + { + var problems = new List(); + + foreach (var unit in _units.Values) + { + if (string.IsNullOrWhiteSpace(unit.Id)) + problems.Add("A unit has no id."); + if (unit.ParentId is { Length: > 0 } parentId && !_units.ContainsKey(parentId)) + problems.Add($"Unit '{unit.Id}' names the parent '{parentId}', which does not exist."); + if (IsInCycle(unit.Id)) + problems.Add($"Unit '{unit.Id}' is part of a cycle — a unit cannot contain itself."); + if (unit.LeaderAgentId is { Length: > 0 } leaderId && LeaderOf(unit.Id) is null) + problems.Add(_agents.ContainsKey(leaderId) + ? $"Unit '{unit.Id}' names '{leaderId}' as lead, but that agent is not a member of it." + : $"Unit '{unit.Id}' names the lead '{leaderId}', which does not exist."); + } + + foreach (var agent in _agents.Values) + { + if (agent.UnitId is { Length: > 0 } unitId && !_units.ContainsKey(unitId)) + problems.Add($"Agent '{agent.AgentId}' belongs to the unit '{unitId}', which does not exist."); + + if (ToolCeiling(KnownUnit(agent)) is { } ceiling) + { + var beyond = agent.Tools.Keys.Where(t => !ceiling.Contains(t)).ToList(); + if (beyond.Count > 0) + problems.Add($"Agent '{agent.AgentId}' has tools beyond its unit's ceiling: {string.Join(", ", beyond)}."); + } + } + + return problems; + } + + // ─── helpers ─── + + private void AddLeadsBelow(string? unitId, HashSet into) + { + foreach (var child in Children(unitId)) + { + if (LeaderOf(child.Id) is { } lead) + { + into.Add(lead.AgentId); + continue; + } + + // No lead there: its members answer to the lead above — so this one sees them, + // and looks further down for the next leads. + foreach (var member in MembersOf(child.Id)) + into.Add(member.AgentId); + AddLeadsBelow(child.Id, into); + } + } + + /// The parent id if it names an existing unit and does not close a cycle. + private string? KnownParent(UnitConfig unit) + => unit.ParentId is { Length: > 0 } parentId && _units.ContainsKey(parentId) && !IsInCycle(unit.Id) + ? _units[parentId].Id + : null; + + private string? KnownUnit(AgentConfig agent) + => agent.UnitId is { Length: > 0 } unitId && _units.TryGetValue(unitId, out var unit) ? unit.Id : null; + + private bool IsInCycle(string unitId) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var current = unitId; + while (current is not null && _units.TryGetValue(current, out var unit)) + { + if (!seen.Add(current)) + return true; + current = unit.ParentId is { Length: > 0 } p ? p : null; + } + return false; + } +} diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs index a0d53e4..3198e27 100644 --- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs +++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs @@ -34,4 +34,11 @@ public sealed record AgentToolContext( /// Ohne Freigabestelle bleibt es false, und das Werkzeug prueft selbst. /// public bool FreigabeGeprueft { get; init; } + + /// + /// #683: The organisation at the time of the call. Tools that let an agent address + /// another — a task assignment, for instance — check the address book here, so it cannot + /// be walked around. null in tests and outside the engine: no structure, no limit. + /// + public Organisation.OrgChart? Organisation { get; init; } } diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index 10c146d..2137930 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -1795,3 +1795,116 @@ td.klein { .team-flaeche { flex-direction: column; } .team-seitenleiste { width: auto; } } + +/* ─── Organisation (#683) ─── */ + +.org-ansicht { + display: flex; + flex-direction: column; + gap: 12px; +} + +.org-raster { + display: grid; + grid-template-columns: minmax(260px, 360px) 1fr; + gap: 16px; + align-items: start; +} + +.org-baum { + background: var(--surface); + border: 1px solid var(--divider); + padding: 12px; +} + +.org-baum-kopf { + justify-content: space-between; + margin-bottom: 8px; +} + +.org-knoten-liste { + display: flex; + flex-direction: column; +} + +.org-ebene-titel { + padding: 6px 12px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +button.org-knoten { + width: 100%; + justify-content: space-between; + border: none; + border-left: 3px solid transparent; + background: transparent; + padding-top: 5px; + padding-bottom: 5px; + text-align: left; +} + +button.org-knoten.aktiv { + border-left-color: var(--accent); + background: var(--surface-hover); +} + +button.org-einheit .org-name { + font-weight: 600; +} + +.org-detail { + display: flex; + flex-direction: column; + gap: 12px; +} + +.org-detail .karte { + display: flex; + flex-direction: column; + gap: 8px; +} + +.org-grund input { + width: 100%; +} + +.org-werkzeuge { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 4px 12px; +} + +.org-werkzeug { + font-size: 13px; +} + +.org-einstellung { + flex-wrap: wrap; +} + +.org-liste { + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; +} + +tr.org-ausreisser td { + background: rgba(181, 130, 42, 0.08); +} + +.org-probleme { + border: 1px solid var(--gefahr); + border-left-width: 4px; + padding: 10px 14px; + font-size: 13px; +} + +.org-probleme ul { + margin: 6px 0 0 18px; +} + +@media (max-width: 900px) { + .org-raster { grid-template-columns: 1fr; } +} diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js index efe8187..cc0fcc4 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.js @@ -36,6 +36,7 @@ import { renderUsageTimeline } from "./views/usage-timeline.js"; import { renderRunDetail } from "./views/run-detail.js"; import { renderPromptHistory } from "./views/prompt-history.js"; import { renderTeam, leaveTeam } from "./views/team.js"; +import { renderOrganisation } from "./views/organisation.js"; // ─── 1. Routen registrieren ─── @@ -53,6 +54,7 @@ registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); }); registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); }); registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); }); +registriereRoute("/organisation", () => { verlasseAnsicht(); return renderOrganisation(); }); registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); }); registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); }); registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); }); diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 30a58ba..15194c8 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -68,6 +68,65 @@ "nav.agents": "Agenten", "nav.tasks": "Aufgaben", "nav.team": "Teamübersicht", + "common.saved": "Gespeichert.", + "common.create": "Anlegen", + "nav.organisation": "Organisation", + "org.title": "Organisation", + "org.subtitle": "Einheiten, Leitungen und Mitglieder. Jeder Agent erreicht nur seine Einheit und seine Leitung — die Struktur ist das Adressbuch.", + "org.problems": "Probleme in der Struktur", + "org.structure": "Struktur", + "org.newUnit": "Neue Einheit", + "org.newSubUnit": "Neue Untereinheit", + "org.topLevel": "Oberste Ebene", + "org.leadShort": "Leitung", + "org.unitId": "Kennung (Buchstaben, Ziffern, - und _)", + "org.unitName": "Name", + "org.parent": "Übergeordnete Einheit", + "org.goal": "Ziel der Einheit", + "org.reason": "Begründung der Änderung (geht ins Audit)", + "org.reasonPlaceholder": "z. B. Marktplätze bekommen ein eigenes Team", + "org.selectHint": "Links eine Einheit oder einen Agenten wählen.", + "org.lead": "Leitung", + "org.noLead": "— keine Leitung (die Leitung darüber übernimmt) —", + "org.leadHint": "Die Leitung muss Mitglied der Einheit sein.", + "org.position": "Platz im Baum", + "org.move": "Verschieben", + "org.moveUnitHint": "Die Mitglieder bleiben in der Einheit; es ändert sich, was darüber liegt. Nicht möglich, solange ein betroffener Agent läuft.", + "org.ceiling": "Rechte-Obergrenze", + "org.ceilingHint": "Was die Einheit nicht darf, kann ein Mitglied nicht bekommen. Senken entzieht den Mitgliedern Werkzeuge — nach einer Bestätigung.", + "org.ceilingLimit": "Werkzeuge dieser Einheit begrenzen", + "org.ceilingInherited": "Von oben begrenzt auf: {tools}", + "org.settings": "Sammelwerte", + "org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.", + "org.setting.DailyCostUsd": "Tagesbudget (USD)", + "org.setting.DailyTokens": "Tagesbudget (Tokens)", + "org.setting.Model": "Modell", + "org.setting.WhenExhausted": "Wenn das Budget erschöpft ist", + "org.modePerAgent": "jeder Agent bekommt den Wert", + "org.modeDistribute": "Wert ist ein Gesamtbetrag, verteilt", + "org.preview": "Vorschau", + "org.apply": "Gewählte Werte schreiben", + "org.agent": "Agent", + "org.current": "Bisher", + "org.proposed": "Neu", + "org.outlier": "Ausreißer", + "org.outlierHint": "Ausreißer weichen stark von ihren Kollegen ab — oft ein Spezialist mit bewusst anderem Wert. Sie sind standardmäßig nicht gewählt.", + "org.rememberDefault": "Als Vorgabe für neue Mitglieder merken", + "org.settingInvalid": "Dieser Wert ist für diese Einstellung nicht gültig.", + "org.members": "Mitglieder", + "org.noMembers": "Noch keine Mitglieder.", + "org.deleteUnit": "Einheit löschen", + "org.unit": "Einheit", + "org.answersTo": "Berichtet an", + "org.human": "einen Menschen", + "org.since": "In dieser Einheit seit {time}", + "org.tools": "Werkzeuge", + "org.moveAgent": "Agent verschieben", + "org.moveAgentHint": "Adressbuch, Leitung und womöglich Werkzeuge ändern sich. Der Agent und beide Leitungen erfahren es in ihrem nächsten Lauf.", + "org.toolsLostTitle": "Werkzeuge würden entfernt", + "org.toolsLostIntro": "Der neue Platz hat eine Rechte-Obergrenze. Diese Agenten verlören Werkzeuge:", + "org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen", + "org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.", "team.title": "Teamübersicht", "team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.", "team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index ee7f842..1e90287 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -68,6 +68,65 @@ "nav.agents": "Agents", "nav.tasks": "Tasks", "nav.team": "Team overview", + "common.saved": "Saved.", + "common.create": "Create", + "nav.organisation": "Organisation", + "org.title": "Organisation", + "org.subtitle": "Units, leads and members. Every agent only reaches its unit and its lead — the structure is the address book.", + "org.problems": "Problems in the structure", + "org.structure": "Structure", + "org.newUnit": "New unit", + "org.newSubUnit": "New sub-unit", + "org.topLevel": "Top level", + "org.leadShort": "lead", + "org.unitId": "Id (letters, digits, - and _)", + "org.unitName": "Name", + "org.parent": "Parent unit", + "org.goal": "Goal of the unit", + "org.reason": "Reason for the change (goes into the audit log)", + "org.reasonPlaceholder": "e.g. marketplaces get their own team", + "org.selectHint": "Select a unit or an agent on the left.", + "org.lead": "Lead", + "org.noLead": "— no lead (the lead above takes over) —", + "org.leadHint": "The lead must be a member of the unit.", + "org.position": "Position in the tree", + "org.move": "Move", + "org.moveUnitHint": "The members stay in the unit; what changes is what lies above it. Not possible while an affected agent is running.", + "org.ceiling": "Tool ceiling", + "org.ceilingHint": "What the unit may not have, a member cannot be granted. Lowering it removes tools from the members — after a confirmation.", + "org.ceilingLimit": "Limit the tools of this unit", + "org.ceilingInherited": "Limited from above to: {tools}", + "org.settings": "Unit-wide values", + "org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.", + "org.setting.DailyCostUsd": "Daily budget (USD)", + "org.setting.DailyTokens": "Daily budget (tokens)", + "org.setting.Model": "Model", + "org.setting.WhenExhausted": "When the budget is exhausted", + "org.modePerAgent": "each agent gets the value", + "org.modeDistribute": "value is a total, distributed", + "org.preview": "Preview", + "org.apply": "Write the selected values", + "org.agent": "Agent", + "org.current": "Current", + "org.proposed": "New", + "org.outlier": "outlier", + "org.outlierHint": "Outliers differ strongly from their colleagues — often a specialist with a deliberately different value. They are not selected by default.", + "org.rememberDefault": "Remember as default for new members", + "org.settingInvalid": "That value is not valid for this setting.", + "org.members": "Members", + "org.noMembers": "No members yet.", + "org.deleteUnit": "Delete unit", + "org.unit": "Unit", + "org.answersTo": "Answers to", + "org.human": "a human", + "org.since": "In this unit since {time}", + "org.tools": "Tools", + "org.moveAgent": "Move agent", + "org.moveAgentHint": "Its address book, its lead and possibly its tools change. The agent and both leads are told in their next run.", + "org.toolsLostTitle": "Tools would be removed", + "org.toolsLostIntro": "The new position has a tool ceiling. These agents would lose tools:", + "org.toolsLostConfirm": "Remove the tools and apply", + "org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.", "team.title": "Team overview", "team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.", "team.noInstance": "No instance selected. Choose an active instance in the header.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/index.html b/src/ClawdDotNet.Manager.Web/wwwroot/index.html index 34b1ee7..8526081 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/index.html +++ b/src/ClawdDotNet.Manager.Web/wwwroot/index.html @@ -61,6 +61,12 @@ Team overview + + + + + Organisation + diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js new file mode 100644 index 0000000..d571e6d --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js @@ -0,0 +1,471 @@ +// ClawdDotNet — Organisation (#683) +// +// Units, leads, members — and the three things that change rights: moving, the tool +// ceiling, unit-wide values. Every rule is the instance's (OrgAdministration); this page +// asks for a preview first wherever something could be lost, and shows it before writing. +// +// A list, not a graph: this is where the structure is edited. Watching it work is the team +// overview (#711). + +import { api } from "../modules/api.js"; +import { erstelleElement, erstelleKarte, erstellePlakette, erstelleModal, zeigeMeldung, formatiereDatumZeit } from "../modules/dom.js"; +import { t } from "../modules/i18n.js"; + +const SETTINGS = ["DailyCostUsd", "DailyTokens", "Model", "WhenExhausted"]; +const NUMERIC = new Set(["DailyCostUsd", "DailyTokens"]); + +let data = null; +let selection = null; // { kind: "unit" | "agent", id } +let treeBox = null; +let detailBox = null; +let reasonInput = null; + +export async function renderOrganisation() { + const container = document.getElementById("ansicht-inhalt"); + if (!container) return; + + treeBox = erstelleElement("div", { klasse: "org-baum" }); + detailBox = erstelleElement("div", { klasse: "org-detail" }); + + container.replaceChildren(erstelleElement( + "div", + { klasse: "org-ansicht" }, + erstelleElement("h1", { text: t("org.title") }), + erstelleElement("p", { klasse: "gedaempft", text: t("org.subtitle") }), + erstelleElement("div", { id: "org-probleme" }), + erstelleElement("div", { klasse: "org-raster" }, treeBox, detailBox) + )); + + await load(); +} + +async function load() { + try { + data = await api("/organisation"); + } catch (err) { + treeBox.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })); + return; + } + + if (selection && !find(selection)) selection = null; + renderProblems(); + renderTree(); + renderDetail(); +} + +// ─── helpers ─── + +function find(sel) { + if (!sel || !data) return null; + return sel.kind === "unit" + ? data.units.find((u) => u.id === sel.id) + : data.agents.find((a) => a.agentId === sel.id); +} + +function childrenOf(unitId) { + return data.units.filter((u) => (u.parentId ?? null) === unitId); +} + +function membersOf(unitId) { + return data.agents.filter((a) => (a.unitId ?? null) === unitId); +} + +function descendantIds(unitId) { + const ids = new Set([unitId]); + let grew = true; + while (grew) { + grew = false; + for (const u of data.units) { + if (u.parentId && ids.has(u.parentId) && !ids.has(u.id)) { ids.add(u.id); grew = true; } + } + } + return ids; +} + +function reason() { + return reasonInput?.value.trim() ?? ""; +} + +async function send(path, method, body, successText) { + try { + const result = await api(path, { method, body }); + zeigeMeldung(result?.message ?? successText ?? t("common.saved"), "erfolg"); + await load(); + return true; + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + return false; + } +} + +/** + * Moves and ceiling changes: ask for a preview, stop on problems or running agents, show + * the tools that would be lost and let the human confirm — only then write. + */ +async function withPreview(path, method, body) { + let preview; + try { + preview = await api(path, { method, body: { ...body, preview: true } }); + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + return; + } + + if (preview.problems.length) { zeigeMeldung(preview.problems.join(" "), "gefahr", 8000); return; } + if (preview.running.length) { + zeigeMeldung(t("org.runningRefusal", { agents: preview.running.join(", ") }), "warnung", 8000); + return; + } + + const lost = Object.entries(preview.toolsLost ?? {}); + if (!lost.length) { + await send(path, method, { ...body, preview: false }); + return; + } + + const list = erstelleElement("ul", { klasse: "org-liste" }, + ...lost.map(([agent, tools]) => erstelleElement("li", { text: `${agent}: ${tools.join(", ")}` }))); + const content = erstelleElement("div", {}, + erstelleElement("p", { text: t("org.toolsLostIntro") }), list); + + const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") }); + const confirm = erstelleElement("button", { type: "button", klasse: "gefahr", text: t("org.toolsLostConfirm") }); + const modal = erstelleModal(t("org.toolsLostTitle"), content, [cancel, confirm]); + cancel.addEventListener("click", () => modal.schliessen()); + confirm.addEventListener("click", async () => { + modal.schliessen(); + await send(path, method, { ...body, preview: false, dropExcessTools: true }); + }); + modal.oeffnen(); +} + +function unitOptions(select, current, exclude = new Set(), topLabel = t("org.topLevel")) { + select.append(erstelleElement("option", { value: "", text: topLabel })); + for (const unit of data.units) { + if (exclude.has(unit.id)) continue; + const option = erstelleElement("option", { value: unit.id, text: unit.path }); + if (unit.id === current) option.selected = true; + select.append(option); + } + if (!current) select.value = ""; + return select; +} + +// ─── problems ─── + +function renderProblems() { + const box = document.getElementById("org-probleme"); + if (!box) return; + box.replaceChildren(...(data.problems.length + ? [erstelleElement("div", { klasse: "org-probleme" }, + erstelleElement("strong", { text: t("org.problems") }), + erstelleElement("ul", {}, ...data.problems.map((p) => erstelleElement("li", { text: p }))))] + : [])); +} + +// ─── tree ─── + +function renderTree() { + const newUnit = erstelleElement("button", { + type: "button", klasse: "primaer klein", text: t("org.newUnit"), onclick: () => openCreateUnit() + }); + + const list = erstelleElement("div", { klasse: "org-knoten-liste" }); + const root = erstelleElement("div", { klasse: "org-ebene" }, + erstelleElement("div", { klasse: "org-ebene-titel gedaempft text-klein", text: t("org.topLevel") })); + for (const agent of membersOf(null)) root.append(agentRow(agent, 0)); + list.append(root); + for (const unit of childrenOf(null)) list.append(unitBlock(unit, 0)); + + treeBox.replaceChildren( + erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" }, + erstelleElement("h2", { text: t("org.structure") }), newUnit), + list); +} + +function unitBlock(unit, depth) { + const lead = unit.leaderAgentId ? ` · ${t("org.leadShort")}: ${unit.leaderAgentId}` : ""; + const count = membersOf(unit.id).length; + const row = erstelleElement("button", { + type: "button", + klasse: `org-knoten org-einheit ${selection?.kind === "unit" && selection.id === unit.id ? "aktiv" : ""}`, + stil: `padding-left: ${12 + depth * 18}px`, + onclick: () => select({ kind: "unit", id: unit.id }) + }, + erstelleElement("span", { klasse: "org-name", text: `▣ ${unit.name}` }), + erstelleElement("span", { klasse: "gedaempft text-klein", text: `${count}${lead}` })); + + const block = erstelleElement("div", {}, row); + for (const agent of membersOf(unit.id)) block.append(agentRow(agent, depth + 1)); + for (const child of childrenOf(unit.id)) block.append(unitBlock(child, depth + 1)); + return block; +} + +function agentRow(agent, depth) { + return erstelleElement("button", { + type: "button", + klasse: `org-knoten org-agent ${selection?.kind === "agent" && selection.id === agent.agentId ? "aktiv" : ""}`, + stil: `padding-left: ${12 + depth * 18}px`, + onclick: () => select({ kind: "agent", id: agent.agentId }) + }, + erstelleElement("span", { text: `${agent.isLead ? "★ " : "· "}${agent.displayName || agent.agentId}` }), + erstelleElement("span", { klasse: "gedaempft text-klein", text: agent.agentId })); +} + +function select(sel) { + selection = sel; + renderTree(); + renderDetail(); +} + +function openCreateUnit(parentId = null) { + const id = erstelleElement("input", { type: "text", placeholder: "marketing", required: true }); + const name = erstelleElement("input", { type: "text", placeholder: "Marketing", required: true }); + const parent = unitOptions(erstelleElement("select"), parentId); + const goal = erstelleElement("textarea", { rows: 2 }); + + const form = erstelleElement("form", { klasse: "formular-raster" }, + erstelleElement("label", { text: t("org.unitId") }), id, + erstelleElement("label", { text: t("org.unitName") }), name, + erstelleElement("label", { text: t("org.parent") }), parent, + erstelleElement("label", { text: t("org.goal") }), goal); + + const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") }); + const create = erstelleElement("button", { type: "submit", klasse: "primaer", text: t("common.create") }); + const modal = erstelleModal(t("org.newUnit"), form, [cancel, create]); + cancel.addEventListener("click", () => modal.schliessen()); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const ok = await send("/organisation/units", "POST", { + id: id.value.trim(), name: name.value.trim(), parentId: parent.value || null, goal: goal.value, reason: reason() + }); + if (ok) { + modal.schliessen(); + select({ kind: "unit", id: id.value.trim() }); + } + }); + modal.oeffnen(); +} + +// ─── detail ─── + +function renderDetail() { + const item = find(selection); + reasonInput = erstelleElement("input", { type: "text", placeholder: t("org.reasonPlaceholder") }); + const reasonCard = erstelleKarte(null, [ + erstelleElement("label", { klasse: "text-klein gedaempft", text: t("org.reason") }), reasonInput + ], { klasse: "org-grund" }); + + if (!item) { + detailBox.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") })); + return; + } + + detailBox.replaceChildren(reasonCard, ...(selection.kind === "unit" ? unitPanel(item) : agentPanel(item))); +} + +function unitPanel(unit) { + const cards = []; + + // Name and goal. + const name = erstelleElement("input", { type: "text", value: unit.name }); + const goal = erstelleElement("textarea", { rows: 2 }); + goal.value = unit.goal ?? ""; + cards.push(erstelleKarte(unit.path, [ + erstelleElement("div", { klasse: "formular-raster" }, + erstelleElement("label", { text: t("org.unitName") }), name, + erstelleElement("label", { text: t("org.goal") }), goal, + erstelleElement("button", { + type: "button", klasse: "primaer klein", text: t("common.save"), + onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}`, "PATCH", + { name: name.value, goal: goal.value, reason: reason() }) + })) + ])); + + // Lead. + const lead = erstelleElement("select", {}, erstelleElement("option", { value: "", text: t("org.noLead") })); + for (const member of membersOf(unit.id)) { + const option = erstelleElement("option", { value: member.agentId, text: member.displayName || member.agentId }); + if (member.agentId === unit.leaderAgentId) option.selected = true; + lead.append(option); + } + cards.push(erstelleKarte(t("org.lead"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.leadHint") }), + erstelleElement("div", { klasse: "gruppe-horizontal" }, lead, + erstelleElement("button", { + type: "button", klasse: "klein", text: t("common.save"), + onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/leader`, "PUT", + { agentId: lead.value || null, reason: reason() }) + })) + ])); + + // Position in the tree. + const parent = unitOptions(erstelleElement("select"), unit.parentId, descendantIds(unit.id)); + cards.push(erstelleKarte(t("org.position"), [ + erstelleElement("div", { klasse: "gruppe-horizontal" }, parent, + erstelleElement("button", { + type: "button", klasse: "klein", text: t("org.move"), + onclick: () => withPreview(`/organisation/units/${encodeURIComponent(unit.id)}/move`, "POST", + { target: parent.value || null, reason: reason() }) + })), + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveUnitHint") }) + ])); + + cards.push(ceilingCard(unit)); + cards.push(settingsCard(unit)); + + // Members. + const members = membersOf(unit.id); + cards.push(erstelleKarte(t("org.members"), [ + members.length + ? erstelleElement("ul", { klasse: "org-liste" }, ...members.map((m) => erstelleElement("li", {}, + erstelleElement("button", { + type: "button", klasse: "flach klein", text: `${m.isLead ? "★ " : ""}${m.displayName || m.agentId}`, + onclick: () => select({ kind: "agent", id: m.agentId }) + })))) + : erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.noMembers") }), + erstelleElement("div", { klasse: "gruppe-horizontal" }, + erstelleElement("button", { + type: "button", klasse: "flach klein", text: t("org.newSubUnit"), onclick: () => openCreateUnit(unit.id) + }), + erstelleElement("button", { + type: "button", klasse: "gefahr klein", text: t("org.deleteUnit"), + onclick: async () => { + if (await send(`/organisation/units/${encodeURIComponent(unit.id)}?reason=${encodeURIComponent(reason())}`, "DELETE")) { + selection = null; + renderDetail(); + } + } + })) + ])); + + return cards; +} + +function ceilingCard(unit) { + const limited = erstelleElement("input", { type: "checkbox" }); + limited.checked = Array.isArray(unit.toolCeiling); + const boxes = data.knownTools.map((tool) => { + const box = erstelleElement("input", { type: "checkbox", value: tool }); + // Pre-selected: the unit's own ceiling — or, without one, what is allowed from above anyway. + const allowed = unit.toolCeiling ?? unit.effectiveToolCeiling; + box.checked = !allowed || allowed.includes(tool); + return erstelleElement("label", { klasse: "org-werkzeug" }, box, ` ${tool}`); + }); + const grid = erstelleElement("div", { klasse: "org-werkzeuge" }, ...boxes); + const sync = () => { grid.hidden = !limited.checked; }; + limited.addEventListener("change", sync); + sync(); + + const inherited = unit.effectiveToolCeiling && !unit.toolCeiling + ? erstelleElement("p", { klasse: "text-klein", text: t("org.ceilingInherited", { tools: unit.effectiveToolCeiling.join(", ") }) }) + : null; + + return erstelleKarte(t("org.ceiling"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.ceilingHint") }), + inherited, + erstelleElement("label", { klasse: "gruppe-horizontal" }, limited, t("org.ceilingLimit")), + grid, + erstelleElement("button", { + type: "button", klasse: "klein", text: t("common.save"), + onclick: () => { + const tools = limited.checked + ? [...grid.querySelectorAll("input:checked")].map((b) => b.value) + : null; + withPreview(`/organisation/units/${encodeURIComponent(unit.id)}/ceiling`, "PUT", { tools, reason: reason() }); + } + }) + ]); +} + +function settingsCard(unit) { + const setting = erstelleElement("select", {}, + ...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) }))); + const value = erstelleElement("input", { type: "text", placeholder: "10" }); + const mode = erstelleElement("select", {}, + erstelleElement("option", { value: "PerAgent", text: t("org.modePerAgent") }), + erstelleElement("option", { value: "Distribute", text: t("org.modeDistribute") })); + const remember = erstelleElement("input", { type: "checkbox" }); + remember.checked = true; + const result = erstelleElement("div"); + + setting.addEventListener("change", () => { mode.disabled = !NUMERIC.has(setting.value); }); + + const preview = async () => { + let rows; + try { + rows = await api(`/organisation/units/${encodeURIComponent(unit.id)}/settings/preview`, { + method: "POST", body: { setting: setting.value, value: value.value.trim(), mode: mode.disabled ? "PerAgent" : mode.value } + }); + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + return; + } + if (!rows.length) { result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: t("org.settingInvalid") })); return; } + + const checks = new Map(); + const table = erstelleElement("table", { klasse: "daten-tabelle" }, + erstelleElement("thead", {}, erstelleElement("tr", {}, + ...["", t("org.agent"), t("org.current"), t("org.proposed"), ""].map((h) => erstelleElement("th", { text: h })))), + erstelleElement("tbody", {}, ...rows.map((row) => { + const box = erstelleElement("input", { type: "checkbox" }); + box.checked = row.selectedByDefault; + checks.set(row.agentId, { box, value: row.proposed }); + return erstelleElement("tr", { klasse: row.outlier ? "org-ausreisser" : "" }, + erstelleElement("td", {}, box), + erstelleElement("td", { text: row.displayName || row.agentId }), + erstelleElement("td", { text: row.current }), + erstelleElement("td", { text: row.proposed }), + erstelleElement("td", {}, row.outlier ? erstellePlakette(`⚠ ${t("org.outlier")}`, "warnung") : "")); + }))); + + const apply = erstelleElement("button", { + type: "button", klasse: "primaer klein", text: t("org.apply"), + onclick: () => { + const values = {}; + for (const [agentId, { box, value: v }] of checks) if (box.checked) values[agentId] = v; + send(`/organisation/units/${encodeURIComponent(unit.id)}/settings`, "POST", { + setting: setting.value, values, reason: reason(), + defaultValue: remember.checked && mode.value === "PerAgent" ? value.value.trim() : null + }); + } + }); + + result.replaceChildren( + rows.some((r) => r.outlier) ? erstelleElement("p", { klasse: "text-klein", text: `⚠ ${t("org.outlierHint")}` }) : "", + table, + erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, remember, t("org.rememberDefault")), + apply); + }; + + return erstelleKarte(t("org.settings"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.settingsHint") }), + erstelleElement("div", { klasse: "gruppe-horizontal org-einstellung" }, setting, value, mode, + erstelleElement("button", { type: "button", klasse: "klein", text: t("org.preview"), onclick: preview })), + result + ]); +} + +function agentPanel(agent) { + const unit = data.units.find((u) => u.id === agent.unitId); + const target = unitOptions(erstelleElement("select"), agent.unitId); + + return [ + erstelleKarte(agent.displayName || agent.agentId, [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: `${agent.agentId} · ${agent.model}` }), + erstelleElement("p", { text: `${t("org.unit")}: ${unit ? unit.path : t("org.topLevel")}${agent.isLead ? ` (★ ${t("org.lead")})` : ""}` }), + erstelleElement("p", { text: `${t("org.answersTo")}: ${agent.effectiveLeadId ?? t("org.human")}` }), + agent.unitSinceUtc ? erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.since", { time: formatiereDatumZeit(agent.unitSinceUtc) }) }) : null, + erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` }) + ]), + erstelleKarte(t("org.moveAgent"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveAgentHint") }), + erstelleElement("div", { klasse: "gruppe-horizontal" }, target, + erstelleElement("button", { + type: "button", klasse: "klein", text: t("org.move"), + onclick: () => withPreview(`/organisation/agents/${encodeURIComponent(agent.agentId)}/move`, "POST", + { target: target.value || null, reason: reason() }) + })) + ]) + ]; +} diff --git a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs index e0c71b3..631e773 100644 --- a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs +++ b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs @@ -151,6 +151,12 @@ public sealed class TaskboardTool : IAgentTool if (string.IsNullOrWhiteSpace(action)) return ToolResult.Fail("'action' is required."); + // #683: A task for another agent is a way of reaching it — the same address book + // applies as for a message, otherwise it could be walked around. + if (action is "task_create" or "task_update" + && CheckAssignee(GetString(input, "assignee"), context) is { } outOfReach) + return ToolResult.Fail(outOfReach); + try { return action switch @@ -170,6 +176,26 @@ public sealed class TaskboardTool : IAgentTool } } + private static string? CheckAssignee(string? assignee, AgentToolContext context) + { + if (context.Organisation is not { HasUnits: true } org || string.IsNullOrWhiteSpace(assignee)) + return null; + if (TaskAssignee.KindOf(assignee) == TaskAssigneeKind.Human) + return null; + + var target = TaskAssignee.AgentId(assignee); + if (target.Length == 0 || string.Equals(target, context.AgentId, StringComparison.OrdinalIgnoreCase)) + return null; + if (org.CanAddress(context.AgentId, target)) + return null; + + var lead = org.EffectiveLeaderOf(context.AgentId); + return $"'{target}' is not in your address book, so you cannot assign tasks to it. " + + (lead is not null + ? $"Assign it to your lead '{lead.AgentId}', who can pass it on." + : "Assign it to @human."); + } + // ─── Aktionen ─── /// diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index f00057f..9905fe7 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -97,7 +97,7 @@ public sealed record ServiceDto( public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = ""); /// Ein neuer Agent. -public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = ""); +public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "", string? UnitId = null); /// Eine Aufgabe vom Taskboard. public sealed record TaskDto( diff --git a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs new file mode 100644 index 0000000..2bddeda --- /dev/null +++ b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs @@ -0,0 +1,63 @@ +namespace ClawdDotNet.Web.Contracts; + +/// #683: The organisation of an instance — units, members, and what is wrong with it. +public sealed record OrganisationDto( + IReadOnlyList Units, + IReadOnlyList Agents, + IReadOnlyList Problems, + IReadOnlyList KnownTools); + +public sealed record UnitDto( + string Id, + string Name, + string? ParentId, + string? LeaderAgentId, + string Goal, + IReadOnlyList? ToolCeiling, + IReadOnlyList? EffectiveToolCeiling, + UnitDefaultsDto Defaults, + string Path, + int Depth); + +public sealed record UnitDefaultsDto(string? Model, decimal? DailyCostUsd, long? DailyTokens, string? WhenExhausted); + +/// An agent as the organisation page shows it: where it is, whom it answers to. +public sealed record OrgMemberDto( + string AgentId, + string DisplayName, + string? UnitId, + bool IsLead, + string? EffectiveLeadId, + DateTime? UnitSinceUtc, + IReadOnlyList Tools, + decimal DailyCostUsd, + long DailyTokens, + string Model); + +public sealed record UnitInputDto(string Id, string Name, string? ParentId = null, string? Goal = null, string Reason = ""); + +public sealed record UnitUpdateDto(string? Name = null, string? Goal = null, string Reason = ""); + +public sealed record LeaderInputDto(string? AgentId, string Reason = ""); + +/// +/// A move or a ceiling change. With nothing is written — the answer +/// says what would happen. confirms removing the tools beyond a +/// ceiling that the preview named. +/// +public sealed record MoveInputDto(string? Target, string Reason = "", bool DropExcessTools = false, bool Preview = false); + +public sealed record CeilingInputDto(IReadOnlyList? Tools, string Reason = "", bool DropExcessTools = false, bool Preview = false); + +public sealed record StructurePreviewDto( + IReadOnlyList AffectedAgents, + IReadOnlyDictionary> ToolsLost, + IReadOnlyList Running, + IReadOnlyList Problems, + bool CanApply); + +public sealed record SettingPreviewInputDto(string Setting, string Value, string Mode = "PerAgent"); + +public sealed record SettingChangeDto(string AgentId, string DisplayName, string Current, string Proposed, bool Outlier, bool SelectedByDefault); + +public sealed record SettingApplyDto(string Setting, IReadOnlyDictionary Values, string Reason = "", string? DefaultValue = null); diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index bc01936..3d6bfcb 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -31,6 +31,7 @@ internal static class ApiEndpoints api.MapUsage(); api.MapAnalytics(); api.MapLogs(); + api.MapOrganisation(); api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto()); app.MapEvents(); @@ -126,7 +127,7 @@ internal static class ApiEndpoints agents.MapPost("/", (AppHost host, NewAgentDto input) => host.AgentAdmin.Add(new NewAgent( - input.AgentId, input.DisplayName, input.Model, input.Description)).ToResponse()); + input.AgentId, input.DisplayName, input.Model, input.Description, input.UnitId)).ToResponse()); agents.MapDelete("/{agentId}", (AppHost host, string agentId) => host.AgentAdmin.Remove(agentId).ToResponse()); diff --git a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs new file mode 100644 index 0000000..e476b42 --- /dev/null +++ b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs @@ -0,0 +1,90 @@ +using ClawdDotNet.App; +using ClawdDotNet.App.Services; +using ClawdDotNet.Web.Contracts; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace ClawdDotNet.Web.Endpoints; + +/// +/// #683: The organisation — units, leads, moves, tool ceilings, unit-wide values. Every +/// decision is made by ; this only translates. +/// +internal static class OrganisationEndpoints +{ + public static void MapOrganisation(this RouteGroupBuilder api) + { + var org = api.MapGroup("/organisation"); + + org.MapGet("/", (AppHost host) => + { + var chart = host.Organisation.Current(); + var units = chart.Units + .OrderBy(u => chart.PathOf(u.Id), StringComparer.OrdinalIgnoreCase) + .Select(u => new UnitDto( + u.Id, u.Name, u.ParentId, u.LeaderAgentId, u.Goal, u.ToolCeiling, + chart.ToolCeiling(u.Id)?.Order(StringComparer.OrdinalIgnoreCase).ToList(), + new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted), + chart.PathOf(u.Id), chart.Depth(u.Id))) + .ToList(); + + var agents = host.Instance.Agents + .OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase) + .Select(a => new OrgMemberDto( + a.AgentId, a.DisplayName, chart.UnitOf(a.AgentId)?.Id, chart.LedBy(a.AgentId) is not null, + chart.EffectiveLeaderOf(a.AgentId)?.AgentId, a.UnitSinceUtc, + a.Tools.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList(), + a.Budget.DailyCostUsd, a.Budget.DailyTokens, a.Model)) + .ToList(); + + return new OrganisationDto(units, agents, chart.Problems(), AgentAdministration.KnownTools); + }); + + org.MapPost("/units", (AppHost host, UnitInputDto input) => + host.Organisation.CreateUnit(input.Id, input.Name, input.ParentId, input.Goal, input.Reason).ToResponse()); + + org.MapPatch("/units/{unitId}", (AppHost host, string unitId, UnitUpdateDto input) => + host.Organisation.UpdateUnit(unitId, input.Name, input.Goal, input.Reason).ToResponse()); + + org.MapDelete("/units/{unitId}", (AppHost host, string unitId, string? reason) => + host.Organisation.DeleteUnit(unitId, reason ?? "").ToResponse()); + + org.MapPut("/units/{unitId}/leader", (AppHost host, string unitId, LeaderInputDto input) => + host.Organisation.SetLeader(unitId, input.AgentId, input.Reason).ToResponse()); + + org.MapPost("/units/{unitId}/move", (AppHost host, string unitId, MoveInputDto input) => + input.Preview + ? Results.Ok(ToDto(host.Organisation.PreviewMoveUnit(unitId, input.Target))) + : host.Organisation.MoveUnit(unitId, input.Target, input.Reason, input.DropExcessTools).ToResponse()); + + org.MapPost("/agents/{agentId}/move", (AppHost host, string agentId, MoveInputDto input) => + input.Preview + ? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target))) + : host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse()); + + org.MapPut("/units/{unitId}/ceiling", (AppHost host, string unitId, CeilingInputDto input) => + input.Preview + ? Results.Ok(ToDto(host.Organisation.PreviewToolCeiling(unitId, input.Tools))) + : host.Organisation.SetToolCeiling(unitId, input.Tools, input.Reason, input.DropExcessTools).ToResponse()); + + org.MapPost("/units/{unitId}/settings/preview", (AppHost host, string unitId, SettingPreviewInputDto input) => + { + if (!Enum.TryParse(input.Setting, ignoreCase: true, out var setting) + || !Enum.TryParse(input.Mode, ignoreCase: true, out var mode)) + return Results.BadRequest(new OutcomeDto(false, "Unknown setting or mode.")); + + return Results.Ok(host.Organisation.PreviewSetting(unitId, setting, input.Value, mode) + .Select(c => new SettingChangeDto(c.AgentId, c.DisplayName, c.Current, c.Proposed, c.Outlier, c.SelectedByDefault)) + .ToList()); + }); + + org.MapPost("/units/{unitId}/settings", (AppHost host, string unitId, SettingApplyDto input) => + Enum.TryParse(input.Setting, ignoreCase: true, out var setting) + ? host.Organisation.ApplySetting(unitId, setting, input.Values, input.Reason, input.DefaultValue).ToResponse() + : Results.BadRequest(new OutcomeDto(false, "Unknown setting."))); + } + + private static StructurePreviewDto ToDto(StructurePreview preview) + => new(preview.AffectedAgents, preview.ToolsLost, preview.Running, preview.Problems, preview.CanApply); +} 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"); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Organisation/AddressBookEnforcementTests.cs b/tests/ClawdDotNet.Core.Tests/Organisation/AddressBookEnforcementTests.cs new file mode 100644 index 0000000..bdc7830 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Organisation/AddressBookEnforcementTests.cs @@ -0,0 +1,178 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.Tests.Infrastructure; +using ClawdDotNet.Core.Tools; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Organisation; + +/// +/// #683: The address book is enforced by the engine — not merely described in a prompt that +/// a model may or may not follow. And every agent is told where it stands. +/// +public sealed class AddressBookEnforcementTests +{ + /// marketing (lead: marketing-lead) › amazon (lead: amazon-lead, ads); accounting: bookkeeper. + private static (EngineFixture Fixture, List Units) Shop() + { + var fixture = new EngineFixture(); + var units = new List + { + new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" }, + new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead", Goal = "Sell on Amazon." }, + new() { Id = "accounting", Name = "Accounting" } + }; + + foreach (var (id, unit) in new[] + { + ("marketing-lead", "marketing"), ("amazon-lead", "amazon"), ("ads", "amazon"), ("bookkeeper", "accounting") + }) + fixture.AddAgent(id).UnitId = unit; + + fixture.Engine.OrganisationProvider = () => new OrgChart(units, fixture.Agents); + return (fixture, units); + } + + // ═══════════════════ messages and spawns ═══════════════════ + + [Fact] + public async Task A_message_outside_the_address_book_is_refused_with_the_way_that_is_open() + { + var (shop, _) = Shop(); + + var result = await shop.Engine.SendMessageAsync("ads", "bookkeeper", "Kannst du zahlen?", "inst", null, default); + + result.Delivered.ShouldBeFalse(); + result.Error.ShouldNotBeNull().ShouldContain("not in your address book"); + result.Error.ShouldContain("amazon-lead", Case.Sensitive, "the way that is open: the own lead"); + shop.Client.CallCount.ShouldBe(0, "refused before any model call"); + } + + [Fact] + public async Task A_message_to_the_own_lead_goes_through() + { + var (shop, _) = Shop(); + shop.Client.RespondsWithText("Verstanden."); + + var result = await shop.Engine.SendMessageAsync("ads", "amazon-lead", "Frage", "inst", null, default); + + result.Delivered.ShouldBeTrue(result.Error); + } + + [Fact] + public async Task A_spawn_is_held_to_the_same_address_book() + { + var (shop, _) = Shop(); + + var result = await shop.Engine.SpawnAgentAsync("ads", "bookkeeper", "Buche das", "inst", null, default); + + result.Started.ShouldBeFalse(); + result.Error.ShouldNotBeNull().ShouldContain("not in your address book"); + } + + [Fact] + public void The_agent_list_shows_only_the_address_book() + { + var (shop, _) = Shop(); + + shop.Engine.ListAgents("ads").Select(a => a.AgentId).ShouldBe(["amazon-lead"]); + } + + [Fact] + public void Without_units_the_agent_list_is_unchanged() + { + var fixture = new EngineFixture(); + fixture.AddAgent("a"); + fixture.AddAgent("b"); + fixture.AddAgent("c"); + + fixture.Engine.ListAgents("a").Select(x => x.AgentId).ShouldBe(["b", "c"], ignoreOrder: true); + } + + // ═══════════════════ delegation depth ═══════════════════ + + [Fact] + public async Task The_delegation_limit_follows_the_depth_of_the_tree() + { + // Five levels deep: with the old fixed limit of 3, the bottom could not be reached. + var fixture = new EngineFixture(); + var units = new List(); + string? parent = null; + for (var level = 1; level <= 5; level++) + { + units.Add(new UnitConfig { Id = $"u{level}", Name = $"U{level}", ParentId = parent, LeaderAgentId = $"lead{level}" }); + fixture.AddAgent($"lead{level}").UnitId = $"u{level}"; + parent = $"u{level}"; + } + fixture.Engine.OrganisationProvider = () => new OrgChart(units, fixture.Agents); + fixture.Client.RespondsWithText("ok"); + + // lead1 → lead2 → lead3 → lead4 → lead5: the fourth hand-over. + var chain = DelegationChain.Root("lead1").Append("lead2").Append("lead3").Append("lead4"); + var result = await fixture.Engine.SendMessageAsync("lead4", "lead5", "weiter", "inst", chain, default); + + result.Delivered.ShouldBeTrue(result.Error); + } + + // ═══════════════════ the organisation section ═══════════════════ + + [Fact] + public async Task Every_run_tells_the_agent_its_unit_and_its_lead() + { + var (shop, _) = Shop(); + shop.Client.RespondsWithText("ok"); + + await shop.Engine.RunAsync(shop.Agents.First(a => a.AgentId == "ads"), "Los", "inst", default); + + var system = shop.Client.ReceivedRequests[0].Messages[0].Content!; + system.ShouldContain(OrgBriefing.Heading); + system.ShouldContain("\"Amazon\" (Marketing / Amazon)"); + system.ShouldContain("Your lead: amazon-lead"); + system.ShouldContain("Goal of your unit: Sell on Amazon."); + system.ShouldNotContain("bookkeeper", Case.Sensitive, "not in the address book — not in the prompt either"); + } + + [Fact] + public async Task A_move_reaches_a_running_chat_context() + { + var (shop, units) = Shop(); + var ads = shop.Agents.First(a => a.AgentId == "ads"); + shop.Client.RespondsWithText("ok").RespondsWithText("ok"); + + await shop.Engine.ChatAsync(ads, "Hallo", "inst", default); + shop.Client.ReceivedRequests[0].Messages[0].Content!.ShouldContain("Your lead: amazon-lead"); + + // Moved to accounting, which has no lead: the next run knows it. + ads.UnitId = "accounting"; + ads.UnitSinceUtc = DateTime.UtcNow; + await shop.Engine.ChatAsync(ads, "Und jetzt?", "inst", default); + + var system = shop.Client.ReceivedRequests[1].Messages[0].Content!; + system.ShouldContain("\"Accounting\""); + system.ShouldContain("this is recent"); + system.ShouldContain("There is no lead agent above you"); + system.ShouldStartWith("Du bist ads.", Case.Sensitive, "the rest of the prompt is untouched"); + } + + [Fact] + public async Task Without_units_the_prompt_has_no_organisation_section() + { + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("solo"); + fixture.Client.RespondsWithText("ok"); + + await fixture.Engine.RunAsync(agent, "Los", "inst", default); + + fixture.Client.ReceivedRequests[0].Messages[0].Content!.ShouldNotContain(OrgBriefing.Heading); + } + + [Fact] + public void The_section_is_replaced_not_stacked() + { + var once = OrgBriefing.WithSection("Base.", "# Organisation\nA"); + var twice = OrgBriefing.WithSection(once, "# Organisation\nB"); + + twice.ShouldBe("Base.\n\n# Organisation\nB"); + OrgBriefing.WithSection(twice, null).ShouldBe("Base."); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Organisation/OrgChartTests.cs b/tests/ClawdDotNet.Core.Tests/Organisation/OrgChartTests.cs new file mode 100644 index 0000000..1d40959 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Organisation/OrgChartTests.cs @@ -0,0 +1,226 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Organisation; + +/// +/// #683: The organisation tree and the address book it cuts. The example is the online +/// shop from the concept, trimmed: +/// +/// +/// (top level) ceo +/// ├── purchasing lead: purchasing-lead +/// │ ├── suppliers lead: suppliers-lead · negotiator +/// │ └── quality (no lead) · sampler +/// ├── marketing lead: marketing-lead +/// │ └── marketplaces lead: marketplaces-lead +/// │ └── amazon lead: amazon-lead · amazon-ads, amazon-listing +/// └── accounting lead: accounting-lead · bookkeeper +/// +/// +public sealed class OrgChartTests +{ + private static UnitConfig Unit(string id, string? parent = null, string? lead = null, List? ceiling = null) + => new() { Id = id, Name = char.ToUpperInvariant(id[0]) + id[1..], ParentId = parent, LeaderAgentId = lead, ToolCeiling = ceiling }; + + private static AgentConfig Agent(string id, string? unit = null, params string[] tools) + => new() { AgentId = id, DisplayName = id, UnitId = unit, Tools = tools.ToDictionary(t => t, _ => new Dictionary()) }; + + private static OrgChart Shop() => new( + [ + Unit("purchasing", lead: "purchasing-lead"), + Unit("suppliers", "purchasing", "suppliers-lead"), + Unit("quality", "purchasing"), + Unit("marketing", lead: "marketing-lead"), + Unit("marketplaces", "marketing", "marketplaces-lead"), + Unit("amazon", "marketplaces", "amazon-lead"), + Unit("accounting", lead: "accounting-lead") + ], + [ + Agent("ceo"), + Agent("purchasing-lead", "purchasing"), + Agent("suppliers-lead", "suppliers"), + Agent("negotiator", "suppliers"), + Agent("sampler", "quality"), + Agent("marketing-lead", "marketing"), + Agent("marketplaces-lead", "marketplaces"), + Agent("amazon-lead", "amazon"), + Agent("amazon-ads", "amazon"), + Agent("amazon-listing", "amazon"), + Agent("accounting-lead", "accounting"), + Agent("bookkeeper", "accounting") + ]); + + // ═══════════════════ the tree ═══════════════════ + + [Fact] + public void Paths_and_depths_follow_the_tree() + { + var org = Shop(); + + org.PathOf("amazon").ShouldBe("Marketing / Marketplaces / Amazon"); + org.Depth("marketing").ShouldBe(1); + org.Depth("amazon").ShouldBe(3); + org.TreeDepth.ShouldBe(3); + } + + [Fact] + public void Delegation_may_reach_from_the_top_to_the_bottom_and_one_step_further() + { + Shop().DelegationDepth(configuredFloor: 3).ShouldBe(4, "before #683 it was a fixed 3"); + Shop().DelegationDepth(configuredFloor: 6).ShouldBe(6, "the configured value stays a floor"); + new OrgChart([], [Agent("a")]).DelegationDepth(3).ShouldBe(3); + } + + // ═══════════════════ leads ═══════════════════ + + [Fact] + public void A_member_answers_to_the_lead_of_its_unit() + => Shop().EffectiveLeaderOf("amazon-ads")!.AgentId.ShouldBe("amazon-lead"); + + [Fact] + public void A_lead_answers_to_the_lead_above() + => Shop().EffectiveLeaderOf("amazon-lead")!.AgentId.ShouldBe("marketplaces-lead"); + + [Fact] + public void In_a_unit_without_lead_the_nearest_lead_above_takes_over() + => Shop().EffectiveLeaderOf("sampler")!.AgentId.ShouldBe("purchasing-lead"); + + [Fact] + public void Above_a_top_level_lead_there_is_only_the_human() + => Shop().EffectiveLeaderOf("marketing-lead").ShouldBeNull(); + + [Fact] + public void A_lead_who_is_not_a_member_does_not_count() + { + var org = new OrgChart([Unit("team", lead: "outsider")], [Agent("outsider"), Agent("member", "team")]); + + org.LeaderOf("team").ShouldBeNull(); + org.Problems().ShouldContain(p => p.Contains("not a member")); + } + + // ═══════════════════ the address book ═══════════════════ + + [Fact] + public void A_member_sees_its_unit_and_its_lead_and_nothing_else() + { + Shop().AddressBook("amazon-ads").ShouldBe(["amazon-lead", "amazon-listing"], ignoreOrder: true); + } + + [Fact] + public void The_logistician_does_not_learn_that_an_ads_agent_exists() + { + // The example from the item: with a flat list, everyone saw everyone. + Shop().CanAddress("bookkeeper", "amazon-ads").ShouldBeFalse(); + Shop().CanAddress("amazon-ads", "bookkeeper").ShouldBeFalse(); + } + + [Fact] + public void A_lead_sees_its_team_its_sub_leads_its_sibling_leads_and_its_own_lead() + { + Shop().AddressBook("marketplaces-lead").ShouldBe( + ["amazon-lead", "marketing-lead"], ignoreOrder: true); + + Shop().AddressBook("purchasing-lead").ShouldBe( + // sub-lead; the lead-less quality team directly; sibling leads; the top level. + ["suppliers-lead", "sampler", "marketing-lead", "accounting-lead", "ceo"], ignoreOrder: true); + } + + [Fact] + public void Members_of_a_lead_less_unit_and_the_lead_above_see_each_other() + { + var org = Shop(); + + org.CanAddress("sampler", "purchasing-lead").ShouldBeTrue(); + org.CanAddress("purchasing-lead", "sampler").ShouldBeTrue(); + } + + [Fact] + public void The_top_level_and_the_top_level_leads_see_each_other() + { + var org = Shop(); + + org.AddressBook("ceo").ShouldBe(["purchasing-lead", "marketing-lead", "accounting-lead"], ignoreOrder: true); + org.CanAddress("marketing-lead", "ceo").ShouldBeTrue(); + } + + [Fact] + public void Every_line_can_be_walked_both_ways() + { + // Whoever I can reach as my lead can reach me: escalation and delegation use the + // same line. + var org = Shop(); + foreach (var agent in org.Units.SelectMany(u => org.MembersOf(u.Id)).Concat(org.MembersOf(null))) + { + foreach (var other in org.AddressBook(agent.AgentId)) + org.CanAddress(other, agent.AgentId).ShouldBeTrue($"{agent.AgentId} → {other} but not back"); + } + } + + [Fact] + public void Without_units_everyone_reaches_everyone_as_before() + { + var org = new OrgChart([], [Agent("a"), Agent("b"), Agent("c")]); + + org.HasUnits.ShouldBeFalse(); + org.AddressBook("a").ShouldBe(["b", "c"], ignoreOrder: true); + } + + [Fact] + public void An_unknown_agent_reaches_nobody() + => Shop().AddressBook("ghost").ShouldBeEmpty(); + + // ═══════════════════ tool ceiling ═══════════════════ + + [Fact] + public void A_ceiling_applies_along_the_whole_path_down() + { + var org = new OrgChart( + [ + Unit("marketing", ceiling: ["WebFetch", "Taskboard", "AgentComm"]), + Unit("amazon", "marketing", ceiling: ["WebFetch", "Taskboard", "FileRW"]) + ], + [Agent("ads", "amazon")]); + + org.ToolCeiling("amazon")!.ShouldBe(["WebFetch", "Taskboard"], ignoreOrder: true, + "FileRW is not allowed further up, AgentComm not here"); + org.ToolCeiling("marketing")!.Count.ShouldBe(3); + org.ToolCeiling(null).ShouldBeNull(); + } + + [Fact] + public void Tools_beyond_the_ceiling_are_named_as_a_problem() + { + var org = new OrgChart( + [Unit("marketing", ceiling: ["WebFetch"])], + [Agent("ads", "marketing", "WebFetch", "Database")]); + + org.Problems().ShouldContain(p => p.Contains("ads") && p.Contains("Database")); + } + + // ═══════════════════ broken structures ═══════════════════ + + [Fact] + public void A_cycle_is_named_and_does_not_hang() + { + var org = new OrgChart([Unit("a", "b"), Unit("b", "a")], [Agent("x", "a")]); + + org.Problems().ShouldContain(p => p.Contains("cycle")); + org.Ancestors("a").Count.ShouldBeLessThan(3); + org.AddressBook("x").ShouldBeEmpty(); + } + + [Fact] + public void Unknown_references_are_named() + { + var org = new OrgChart([Unit("team", "nowhere")], [Agent("x", "missing")]); + + org.Problems().ShouldContain(p => p.Contains("nowhere")); + org.Problems().ShouldContain(p => p.Contains("missing")); + } + + [Fact] + public void A_sound_structure_has_no_problems() + => Shop().Problems().ShouldBeEmpty(); +} diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/AssignmentReachTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/AssignmentReachTests.cs new file mode 100644 index 0000000..02f6162 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/AssignmentReachTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using ClawdDotNet.Core.Tools; +using ClawdDotNet.Tools.Taskboard; +using ClawdDotNet.Tools.Tests.FileRW; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.Taskboard; + +/// +/// #683: A task for another agent is a way of reaching it. The address book that holds for +/// messages holds here too — otherwise it could be walked around with task_create. +/// +public sealed class AssignmentReachTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteStorage _storage; + private readonly SqliteTaskRepository _repo; + private readonly TaskboardTool _tool = new(); + + private readonly OrgChart _org = new( + [ + new UnitConfig { Id = "amazon", Name = "Amazon", LeaderAgentId = "amazon-lead" }, + new UnitConfig { Id = "accounting", Name = "Accounting", LeaderAgentId = "accounting-lead" } + ], + [ + new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" }, + new AgentConfig { AgentId = "ads", UnitId = "amazon" }, + new AgentConfig { AgentId = "accounting-lead", UnitId = "accounting" }, + new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" } + ]); + + public AssignmentReachTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _repo = new SqliteTaskRepository(_storage); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* cleanup is secondary */ } + } + + private Task Run(string agentId, string json) + { + var context = new AgentToolContext( + agentId, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, + CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo) + { + Organisation = _org + }; + return _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, context, CancellationToken.None); + } + + [Fact] + public async Task A_task_for_an_agent_outside_the_address_book_is_refused() + { + var result = await Run("ads", """{"action":"task_create","title":"Rechnung buchen","assignee":"@bookkeeper"}"""); + + result.Success.ShouldBeFalse(); + result.ErrorMessage.ShouldNotBeNull().ShouldContain("not in your address book"); + result.ErrorMessage.ShouldContain("amazon-lead"); + } + + [Fact] + public async Task A_fresh_run_of_an_agent_outside_is_refused_too() + { + var result = await Run("ads", """{"action":"task_create","title":"Rechnung buchen","assignee":"@new:bookkeeper"}"""); + + result.Success.ShouldBeFalse(); + } + + [Fact] + public async Task A_task_for_the_own_lead_or_the_human_goes_through() + { + (await Run("ads", """{"action":"task_create","title":"Frage an die Leitung","assignee":"@amazon-lead"}""")).Success.ShouldBeTrue(); + (await Run("ads", """{"action":"task_create","title":"Frage an den Menschen","assignee":"@human"}""")).Success.ShouldBeTrue(); + (await Run("ads", """{"action":"task_create","title":"Eigene Aufgabe","assignee":"@ads"}""")).Success.ShouldBeTrue(); + } + + [Fact] + public async Task Reassigning_is_held_to_the_same_rule() + { + var created = await Run("ads", """{"action":"task_create","title":"Eigene Aufgabe","assignee":"@ads"}"""); + var id = created.Content!.Split(' ', ':', '\n').First(w => w.StartsWith("t-")); + + var result = await Run("ads", $$"""{"action":"task_update","id":"{{id}}","assignee":"@bookkeeper"}"""); + + result.Success.ShouldBeFalse(); + } +}