Einheiten, Stufe 1: Organisationsbaum und Adressbuch als reine Rechnung (#683)
- UnitConfig (Id, Name, ParentId, LeaderAgentId, Goal, ToolCeiling, Defaults) in der Instanzkonfiguration neben den Agenten; AgentConfig.UnitId und UnitSinceUtc. Bewusst Konfiguration statt Datenbank: Die Werkzeugrechte der Agenten stehen schon dort, die Wahrheit ueber einen Agenten bleibt an einem Ort. Strukturaenderungen gehen ins Audit (Stufe 3). - OrgChart: Pfade, Tiefe, Leitung, effektive Leitung (die naechste Leitung darueber, wenn die eigene Einheit keine hat oder man sie selbst leitet), Adressbuch, Delegationstiefe aus dem Baum (Tiefe + 1, der eingestellte Wert bleibt Untergrenze), Rechte-Obergrenze als Schnittmenge entlang des Pfades, und eine Liste der Probleme (unbekannte Verweise, Zyklen, Leitung ist kein Mitglied, Werkzeuge ueber der Obergrenze). - Adressbuch: eigene Einheit und effektive Leitung; als Leitung die Leitungen der Untereinheiten (bei leitungslosen Untereinheiten deren Mitglieder), der Geschwistereinheiten und die eigene Leitung; die oberste Ebene und die Leitungen der obersten Einheiten sehen sich gegenseitig. Jede Linie ist in beide Richtungen begehbar. Ohne Einheiten sieht jeder jeden, wie bisher. Tests: OrgChartTests (20) am Onlinehandel-Beispiel aus dem Konzept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dc11961805
commit
d014a20c38
@@ -10,6 +10,17 @@ public sealed class AgentConfig
|
|||||||
[JsonPropertyName("displayName")]
|
[JsonPropertyName("displayName")]
|
||||||
public string DisplayName { get; set; } = "";
|
public string DisplayName { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>#683: The unit this agent belongs to; <c>null</c> for the top level.</summary>
|
||||||
|
[JsonPropertyName("unitId")]
|
||||||
|
public string? UnitId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: Since when the agent is in its unit (UTC) — so it can be told that it moved,
|
||||||
|
/// and its lead that someone joined.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("unitSinceUtc")]
|
||||||
|
public DateTime? UnitSinceUtc { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("model")]
|
[JsonPropertyName("model")]
|
||||||
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
|
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,13 @@ public sealed class InstanceConfig
|
|||||||
[JsonPropertyName("agents")]
|
[JsonPropertyName("agents")]
|
||||||
public List<AgentConfig> Agents { get; set; } = new();
|
public List<AgentConfig> Agents { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: Departments, teams, groups — the organisation tree. Empty means no structure:
|
||||||
|
/// every agent can reach every other, as before units existed.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("units")]
|
||||||
|
public List<UnitConfig> Units { get; set; } = new();
|
||||||
|
|
||||||
[JsonPropertyName("services")]
|
[JsonPropertyName("services")]
|
||||||
public List<ServiceConfig> Services { get; set; } = new();
|
public List<ServiceConfig> Services { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: A unit — department, team, group — as a node in the organisation tree.
|
||||||
|
///
|
||||||
|
/// <para><b>Stored in the instance configuration, next to the agents.</b> 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.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>No value here overrides an agent at run time</b> (Organisation-Konzept, section
|
||||||
|
/// 3). <see cref="Defaults"/> are only the starting values for agents that join;
|
||||||
|
/// <see cref="ToolCeiling"/> is checked when an agent's tools are written, never laid over
|
||||||
|
/// them while it runs. What stands at the agent is what applies.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UnitConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>The parent unit; <c>null</c> for a top-level unit.</summary>
|
||||||
|
[JsonPropertyName("parentId")]
|
||||||
|
public string? ParentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The agent leading this unit — one of its members. May be empty: then the nearest
|
||||||
|
/// unit above that has a lead takes over.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("leaderAgentId")]
|
||||||
|
public string? LeaderAgentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>What the unit exists for — the top of every task chain in it (#714).</summary>
|
||||||
|
[JsonPropertyName("goal")]
|
||||||
|
public string Goal { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tools members of this unit may have at most; <c>null</c> means no ceiling. A
|
||||||
|
/// ceiling applies to sub-units as well — the effective ceiling is the intersection
|
||||||
|
/// along the path to the root.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("toolCeiling")]
|
||||||
|
public List<string>? ToolCeiling { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Starting values for agents that join this unit.</summary>
|
||||||
|
[JsonPropertyName("defaults")]
|
||||||
|
public UnitDefaults Defaults { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: What an agent gets when it joins a unit. <c>null</c> leaves the agent's own value.
|
||||||
|
/// Changing these does not touch existing members — writing a value into them is a
|
||||||
|
/// separate, previewed step.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Organisation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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.
|
||||||
|
///
|
||||||
|
/// <para><b>One rule holds it together: an agent knows only its unit and its lead.</b>
|
||||||
|
/// 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.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Pure.</b> 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.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Without units nothing changes:</b> every agent reaches every other, as before.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrgChart
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, UnitConfig> _units;
|
||||||
|
private readonly Dictionary<string, AgentConfig> _agents;
|
||||||
|
private readonly Dictionary<string, List<UnitConfig>> _children;
|
||||||
|
private readonly Dictionary<string, List<AgentConfig>> _members;
|
||||||
|
|
||||||
|
/// <summary>The key under which top-level units and agents without a unit are grouped.</summary>
|
||||||
|
private const string Root = "";
|
||||||
|
|
||||||
|
public OrgChart(IEnumerable<UnitConfig> units, IEnumerable<AgentConfig> agents)
|
||||||
|
{
|
||||||
|
_units = new Dictionary<string, UnitConfig>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var unit in units)
|
||||||
|
_units.TryAdd(unit.Id, unit);
|
||||||
|
|
||||||
|
_agents = new Dictionary<string, AgentConfig>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var agent in agents)
|
||||||
|
_agents.TryAdd(agent.AgentId, agent);
|
||||||
|
|
||||||
|
_children = new Dictionary<string, List<UnitConfig>>(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<string, List<AgentConfig>>(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);
|
||||||
|
|
||||||
|
/// <summary>Whether the instance has any structure at all.</summary>
|
||||||
|
public bool HasUnits => _units.Count > 0;
|
||||||
|
|
||||||
|
public IReadOnlyCollection<UnitConfig> Units => _units.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);
|
||||||
|
|
||||||
|
/// <summary>The unit an agent belongs to; <c>null</c> for the top level (or an unknown unit).</summary>
|
||||||
|
public UnitConfig? UnitOf(string agentId)
|
||||||
|
=> _agents.TryGetValue(agentId, out var agent) ? Unit(KnownUnit(agent)) : null;
|
||||||
|
|
||||||
|
/// <summary>Direct sub-units; <c>null</c> asks for the top-level units.</summary>
|
||||||
|
public IReadOnlyList<UnitConfig> Children(string? unitId)
|
||||||
|
=> _children.TryGetValue(unitId ?? Root, out var list) ? list : [];
|
||||||
|
|
||||||
|
/// <summary>Direct members; <c>null</c> asks for the agents on the top level.</summary>
|
||||||
|
public IReadOnlyList<AgentConfig> MembersOf(string? unitId)
|
||||||
|
=> _members.TryGetValue(unitId ?? Root, out var list) ? list : [];
|
||||||
|
|
||||||
|
/// <summary>The units above, nearest first — not including the unit itself.</summary>
|
||||||
|
public IReadOnlyList<UnitConfig> Ancestors(string unitId)
|
||||||
|
{
|
||||||
|
var result = new List<UnitConfig>();
|
||||||
|
var seen = new HashSet<string>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"Marketing / Marketplaces / Amazon".</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Depth of a unit: a top-level unit is 1.</summary>
|
||||||
|
public int Depth(string unitId) => Unit(unitId) is null ? 0 : Ancestors(unitId).Count + 1;
|
||||||
|
|
||||||
|
/// <summary>The deepest unit's depth; 0 without units.</summary>
|
||||||
|
public int TreeDepth => _units.Keys.Select(Depth).DefaultIfEmpty(0).Max();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public int DelegationDepth(int configuredFloor) => Math.Max(configuredFloor, TreeDepth + 1);
|
||||||
|
|
||||||
|
/// <summary>The lead of a unit, if it has one that is actually a member.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The unit this agent leads, if any.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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. <c>null</c> means: the human.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everyone the agent may address — the cut of the address book (section 3):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>the members of its own unit and its effective lead;</item>
|
||||||
|
/// <item>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;</item>
|
||||||
|
/// <item>on the top level: each other and the leads of the top-level units, both ways.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Without units: everyone.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlySet<string> AddressBook(string agentId)
|
||||||
|
{
|
||||||
|
var result = new HashSet<string>(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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tools a member of the unit may have at most: the intersection of the ceilings
|
||||||
|
/// along the path to the top. <c>null</c> means no ceiling anywhere on the path.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlySet<string>? ToolCeiling(string? unitId)
|
||||||
|
{
|
||||||
|
if (Unit(unitId) is not { } unit)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
HashSet<string>? 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<string>(node.ToolCeiling, StringComparer.OrdinalIgnoreCase);
|
||||||
|
else
|
||||||
|
ceiling.IntersectWith(node.ToolCeiling);
|
||||||
|
}
|
||||||
|
return ceiling;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> Problems()
|
||||||
|
{
|
||||||
|
var problems = new List<string>();
|
||||||
|
|
||||||
|
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<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The parent id if it names an existing unit and does not close a cycle.</summary>
|
||||||
|
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<string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
using ClawdDotNet.Core.Organisation;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Organisation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #683: The organisation tree and the address book it cuts. The example is the online
|
||||||
|
/// shop from the concept, trimmed:
|
||||||
|
///
|
||||||
|
/// <code>
|
||||||
|
/// (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
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrgChartTests
|
||||||
|
{
|
||||||
|
private static UnitConfig Unit(string id, string? parent = null, string? lead = null, List<string>? 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<string, object?>()) };
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user