diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs index 1bbbd92..39b2039 100644 --- a/src/ClawdDotNet.Core/Config/AgentConfig.cs +++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs @@ -10,6 +10,17 @@ 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; } + [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/Organisation/OrgChart.cs b/src/ClawdDotNet.Core/Organisation/OrgChart.cs new file mode 100644 index 0000000..b11693e --- /dev/null +++ b/src/ClawdDotNet.Core/Organisation/OrgChart.cs @@ -0,0 +1,322 @@ +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 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/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(); +}