Author SHA1 Message Date
RichardandClaude Opus 5.5 920d3fed64 Lagebild, Stufe 1: Einheiten als aufklappbare Container (#711)
- TeamOverview.RollUp: je Einheit die Zustaende aller Agenten darin und
  darunter, offene Freigaben und der dringlichste Zustand (Urgency:
  haengt > Problem > Freigabe > Budget > arbeitet > frei). Gezaehlt wird in
  der Instanz, die Seite zeichnet nur.
- /api/team liefert Einheiten und je Agent Einheit und Leitung.
- team.js: Einheiten als Container, zugeklappt als Kasten mit
  Zusammenfassung ("6 agents · ⚠ 1 · ✋ 1 · ▶ 2") in der Farbe des
  dringlichsten Zustands. Kanten laufen zum sichtbaren Stellvertreter und
  werden gebuendelt (×n); was in einer zugeklappten Einheit bleibt, wird
  nicht gezeichnet. Bis 15 Agenten alles offen, darueber nur die oberste
  Ebene; der Zustand je Instanz im localStorage.
- Seitenleiste fuer Einheiten: Pfad, Ziel, Mitglieder (Leitung zuerst),
  Untereinheiten, Auf-/Zuklappen, Link zur Organisation.
- Layout: rectpacking trifft das Seitenverhaeltnis nur grob; es werden
  bis zu vier Vorgaben probiert und die zur Flaeche passendste behalten.
- Sprachschluessel en/de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:27:33 +02:00
RichardandClaude Opus 5.5 5b8656d969 Tests: Verbindungspools nur der eigenen Datenbank leeren statt aller
Die zufaelligen roten Laeufe der vollen Suite (jedes Mal ein anderer Test,
zuletzt PromptHistoryTests mit einer reinen Funktion) hatten eine Ursache:
Zwanzig Testklassen riefen beim Aufraeumen SqliteConnection.ClearAllPools()
auf. Das ist prozessweit und nahm einem parallel laufenden Test die
Verbindung mitten im Oeffnen weg - ObjectDisposedException in ApplyPragmas.

- SqliteStorage.ReleasePooledConnections(pfad) leert nur die Pools der
  Datenbanken unter einer Datei oder einem Verzeichnis.
- BackupService oeffnet die Quelle fuer VACUUM INTO ohne Pool: eine
  einmalige Kopie soll die Datei danach nicht offen halten.
- PoolReleaseTests: die freigegebene Datenbank laesst sich loeschen, die
  andere arbeitet weiter.

Vier volle Laeufe hintereinander gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:27:33 +02:00
RichardandClaude Opus 5.5 f8d0ce94fe Test: Parallelitaet verschiedener Agenten ueber Ueberlappung statt Wanduhr pruefen
Verschiedene_Agenten_laufen_wirklich_parallel liess die Attrappe nur mit Text
antworten: Das langsame Werkzeug lief nie, und geprueft wurde, ob drei leere
Laeufe unter 450 ms bleiben. Das bestand auch mit gegenseitig gesperrten
Agenten und schlug unter Last fehl. Nach dem Merge von #683 fiel ein Core-Test
einmal in einem parallelen Volllauf, danach in elf Laeufen nicht mehr; dieser
war der letzte mit einer engen Wanduhr-Schwelle. Jetzt ruft jeder Agent das
Werkzeug auf, und bewiesen wird die Ueberlappung (MaxConcurrent > 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:59:46 +02:00
RichardandClaude Opus 5.5 f34756f4bb Merge feat/683-einheiten: Einheiten, Adressbuch, Verwaltung, Organisationsseite (#683)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:54:17 +02:00
RichardandClaude Opus 5.5 e4ecf2d5ca Einheiten, Stufe 4: API und WebUI-Seite "Organisation" (#683)
- /api/organisation: Baum (Einheiten mit Pfad, Tiefe, eigener und
  wirksamer Obergrenze, Vorgaben; Agenten mit Einheit, Leitung, effektiver
  Leitung, Werkzeugen, Budget), Probleme der Struktur, bekannte Werkzeuge.
  Anlegen, Aendern, Loeschen, Leitung, Umzug von Agent und Einheit,
  Obergrenze, Sammelwerte mit Vorschau. Umzug und Obergrenze kennen
  preview=true; die Entscheidung trifft allein OrgAdministration.
- Neue Agenten koennen ueber die API gleich in eine Einheit gelegt werden.
- WebUI #/organisation: links der Baum (oberste Ebene, Einheiten, Leitungen
  mit Stern, Mitglieder), rechts die Bearbeitung. Eine Begruendung fuer das
  Audit gilt fuer jede Aenderung. Wo etwas verloren gehen kann, zuerst die
  Vorschau: laufende Agenten und Probleme halten an, wegfallende Werkzeuge
  muessen in einem Dialog bestaetigt werden. Sammelwerte als Tabelle mit
  Ausreissern markiert und abgewaehlt. Bei aktivierter Begrenzung ist
  vorausgewaehlt, was von oben ohnehin erlaubt ist. Sprachpakete en/de.

Geprueft im Browser mit einem Pruefgestell (API durch Attrappe ersetzt):
Baum, Auswahl, Obergrenze mit Bestaetigungsdialog, Sammelwert-Vorschau
mit Ausreisser und das Schreiben nur der gewaehlten Agenten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:54:16 +02:00
RichardandClaude Opus 5.5 68a432fec6 Einheiten, Stufe 3: Verwaltung mit Umzug, Rechte-Obergrenze und Sammelwerten (#683)
OrgAdministration (App), damit WebUI und Desktop dieselben Regeln anwenden:
- Einheiten anlegen, umbenennen, Ziel setzen, loeschen (nur leer); Leitung
  setzen (nur ein Mitglied).
- Agent umziehen: haelt fest, seit wann und woher (UnitSinceUtc,
  PreviousUnitId); eine Leitung, die geht, laesst ihre Einheit ohne Leitung.
  Einheit umziehen: nie unter sich selbst; die Mitglieder bleiben in ihrer
  Einheit, ihre Position aendert sich, und das wird ihnen gesagt.
- Kein Umzug unter einem laufenden Agenten. Das Konzept sagt "bei gestoppter
  Instanz", damit niemand mitten im Lauf andere Rechte bekommt. Die Verwaltung
  laeuft in der Instanz und kann sie nicht stoppen; sie lehnt ab, solange ein
  betroffener Agent laeuft. Das dient demselben Zweck, nur enger.
- Rechte-Obergrenze: beim Schreiben geprueft (SetToolEnabled, SetToolSettings,
  und als Sicherheitsnetz Save() fuer den Desktop, der Werkzeuge direkt
  umschaltet). Obergrenze senken und in eine Obergrenze umziehen zeigen in
  der Vorschau, welche Werkzeuge wegfallen, und brauchen dafuer eine
  ausdrueckliche Bestaetigung.
- Sammelwerte (Tagesbudget, Tokens, Modell, whenExhausted) mit Vorschau fuer
  die Einheit samt Untereinheiten: ein Wert je Agent oder ein verteilter
  Gesamtbetrag im Verhaeltnis der bisherigen Werte. Ausreisser (Faktor 3 zum
  Median der anderen) sind markiert und standardmaessig nicht gewaehlt.
  Geschrieben wird nur, was gewaehlt ist; die Einheit merkt sich den Wert nur
  als Vorgabe fuer neue Mitglieder.
- Ein neuer Agent, der direkt in eine Einheit gelegt wird, bekommt deren
  Vorgaben; ein umgezogener behaelt seine Werte.
- Jede Strukturaenderung steht mit Begruendung im Audit (Quelle "admin",
  Werkzeug "Organisation").

Der Organisationsabschnitt nennt der Leitung jetzt auch, wer die Einheit
kuerzlich verlassen hat und wohin: Die abgebende Leitung wird unterrichtet,
nicht nur die aufnehmende.

Tests: OrgAdministrationTests (16). 1.333 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:48:34 +02:00
RichardandClaude Opus 5.5 626fc8d593 Einheiten, Stufe 2: das Adressbuch durchsetzen und jedem Agenten seinen Platz sagen (#683)
Durchgesetzt vom Code, nicht nur im Prompt beschrieben:
- Nachricht und Spawn an einen Agenten ausserhalb des Adressbuchs werden vor
  jedem Modellaufruf abgelehnt; die Antwort nennt den offenen Weg (die eigene
  Leitung, sonst @human).
- list_agents zeigt nur noch das Adressbuch.
- task_create und task_update pruefen die Zuweisung an einen Agenten gegen
  dasselbe Adressbuch; sonst liesse es sich ueber eine Aufgabe umgehen.
- Die Delegationstiefe folgt der Tiefe des Baums (Tiefe + 1), der
  eingestellte Wert bleibt Untergrenze.

Unterrichtet werden, wie das Konzept verlangt:
- OrgBriefing erzeugt einen Abschnitt "# Organisation" im Systemprompt:
  Einheit mit Pfad und Ziel, seit wann (mit Hinweis, wenn frisch), die
  effektive Leitung oder "@human", als Leitung die Untereinheiten und neue
  Mitglieder, und wen man sonst erreicht. Er ist immer wahr, weil er aus dem
  Baum kommt; ein Umzug aendert ihn von selbst.
- In einem laufenden Chat wird nur dieser Abschnitt ersetzt; der uebrige
  Systemprompt bleibt, wie er war (#193).
- Ohne Einheiten gibt es keinen Abschnitt und keine Einschraenkung. Bestehende
  Instanzen behalten ihren Prompt und ihren Prompt-Cache.

AppHost setzt den Organisationsbaum als Provider (frisch je Frage, eine
Aenderung gilt ab dem naechsten Lauf) und protokolliert Probleme der Struktur
beim Start.

Tests: AddressBookEnforcementTests (10), AssignmentReachTests (4). 1.317 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:43:10 +02:00
RichardandClaude Opus 5.5 d014a20c38 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>
2026-09-24 12:39:11 +02:00
RichardandClaude Opus 5.5 dc11961805 Merge feat/711-lagebild: Teamuebersicht, Stufe 0 (#711)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:34:46 +02:00
52 changed files with 3739 additions and 141 deletions
+11 -1
View File
@@ -114,7 +114,8 @@ public sealed class AppHost : IAsyncDisposable
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents);
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents,
organisation: () => Core.Organisation.OrgChart.From(Instance));
public TelegramClientManager? TelegramClient { get; private init; }
public OpenRouterStatusService? Status { get; private init; }
@@ -214,6 +215,9 @@ public sealed class AppHost : IAsyncDisposable
/// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary>
public AgentAdministration AgentAdmin => _agentAdmin.Value;
/// <summary>#683: Units, moves, tool ceilings, unit-wide values. Stateless.</summary>
public OrgAdministration Organisation => new(this, LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation"));
/// <summary>Dienste der Instanz anlegen, entfernen, umschalten.</summary>
public ServiceAdministration ServiceAdmin => _serviceAdmin.Value;
@@ -565,6 +569,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(
@@ -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);
/// <summary>
/// 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<string, object?>();
}
@@ -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)
/// </summary>
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 <c>AgentSettings.json</c> danebenstünde, überschriebe
/// der nächste Ladevorgang ohnehin.</para>
/// </summary>
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
@@ -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;
/// <summary>A unit-wide value that can be written into the members (#683).</summary>
public enum UnitSetting
{
DailyCostUsd,
DailyTokens,
Model,
WhenExhausted
}
/// <summary>How a unit-wide number is turned into per-agent values.</summary>
public enum UnitSettingMode
{
/// <summary>Every agent gets the value ("each gets 10 $").</summary>
PerAgent,
/// <summary>The value is a total, split in proportion to the current values ("the unit gets 100 $").</summary>
Distribute
}
/// <summary>One line of a preview: what would change at one agent.</summary>
public sealed record UnitSettingChange(
string AgentId,
string DisplayName,
string Current,
string Proposed,
bool Outlier,
bool SelectedByDefault);
/// <summary>What a move or a lowered ceiling would do before it is done.</summary>
public sealed record StructurePreview(
IReadOnlyList<string> AffectedAgents,
IReadOnlyDictionary<string, IReadOnlyList<string>> ToolsLost,
IReadOnlyList<string> Running,
IReadOnlyList<string> Problems)
{
public bool CanApply => Running.Count == 0 && Problems.Count == 0;
}
/// <summary>
/// #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 <see cref="AgentAdministration"/>).
///
/// <para><b>Three rules from the concept (section 3):</b></para>
/// <list type="number">
/// <item><b>No value overrides another at run time.</b> 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.</item>
/// <item><b>The tool ceiling is checked when writing.</b> A tool beyond it cannot be
/// granted; lowering it removes the tools from the agents, after a preview.</item>
/// <item><b>A move never happens under a running agent.</b> 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.</item>
/// </list>
///
/// <para>Every structural change is written to the audit log with its reason — a change of
/// structure is a change of rights (#205, #641).</para>
/// </summary>
public sealed class OrgAdministration(AppHost host, ILogger logger)
{
/// <summary>A value this far from the median of the others is an outlier.</summary>
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.");
}
/// <summary>Only an empty unit can go — members and sub-units have to be moved first.</summary>
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.");
}
/// <summary>The lead must be a member of the unit. <c>null</c> leaves it without a lead.</summary>
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<string>();
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);
}
/// <summary>
/// Moves an agent. Tools beyond the new unit's ceiling are removed only with
/// <paramref name="dropExcessTools"/> — otherwise the move is refused and says which.
/// A lead leaving its unit leaves it without a lead.
/// </summary>
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<string>();
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);
}
/// <summary>
/// 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.
/// </summary>
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<string>? tools)
{
var chart = Chart;
var problems = new List<string>();
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));
}
/// <summary>
/// Sets the ceiling. Lowering it removes the tools beyond it from the members — shown by
/// <see cref="PreviewToolCeiling"/> first, and only with <paramref name="dropExcessTools"/>,
/// like a move. <c>null</c> lifts the ceiling.
/// </summary>
public AdminResult SetToolCeiling(
string unitId, IReadOnlyCollection<string>? 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)}.");
}
/// <summary>
/// Whether an agent may be granted a tool — called by <see cref="AgentAdministration"/>
/// when writing. <c>null</c> means yes; otherwise the reason.
/// </summary>
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 ───
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<UnitSettingChange> 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<decimal> 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();
}
/// <summary>
/// Writes the values chosen from a preview into the agents, one audit entry each, and —
/// with <paramref name="rememberAsDefault"/> — keeps the value as the unit's default for
/// agents that join.
/// </summary>
public AdminResult ApplySetting(
string unitId, UnitSetting setting, IReadOnlyDictionary<string, string> 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.");
}
/// <summary>
/// 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.
/// </summary>
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<string> agents, string? targetUnitId, (string UnitId, string? NewParent)? movingUnit,
List<string> problems, (string UnitId, IReadOnlyCollection<string>? 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<string, IReadOnlyList<string>>(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<UnitConfig> Subtree(OrgChart chart, string unitId)
{
if (chart.Unit(unitId) is not { } root)
yield break;
var stack = new Stack<UnitConfig>([root]);
var seen = new HashSet<string>(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<decimal> 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);
}
}
}
+77 -6
View File
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
namespace ClawdDotNet.App.Services;
@@ -40,10 +41,33 @@ public sealed record TeamMember(
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs);
long TypicalRunMs,
string? UnitId = null,
bool IsLead = false);
/// <summary>The whole picture at one moment: every agent, and who reached whom recently.</summary>
public sealed record TeamSnapshot(IReadOnlyList<TeamMember> Members, IReadOnlyList<AgentLink> RecentLinks, DateTime AtUtc);
/// <summary>
/// One unit in the team overview (#711, stage 1), with what happens inside it rolled up —
/// its own members and those of every unit below. A collapsed container shows this, so a
/// problem three levels down is still visible from the top.
/// </summary>
public sealed record TeamUnit(
string Id,
string Name,
string? ParentId,
string Path,
string? LeaderAgentId,
string? Goal,
int Agents,
IReadOnlyDictionary<TeamState, int> States,
int PendingApprovals,
TeamState Worst);
/// <summary>The whole picture at one moment: every agent and unit, and who reached whom recently.</summary>
public sealed record TeamSnapshot(
IReadOnlyList<TeamMember> Members,
IReadOnlyList<AgentLink> RecentLinks,
DateTime AtUtc,
IReadOnlyList<TeamUnit>? Units = null);
/// <summary>
/// #711: The team overview — who is working, who reached whom, where something is stuck.
@@ -63,7 +87,8 @@ public sealed class TeamOverview(
IAuditRepository? audit,
IUsageRepository? usage,
Func<IReadOnlyList<AgentConfig>> agents,
TimeProvider? clock = null)
TimeProvider? clock = null,
Func<OrgChart>? organisation = null)
{
/// <summary>How far back the last receipt of each agent is looked for.</summary>
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
@@ -106,6 +131,8 @@ public sealed class TeamOverview(
}
}
var org = organisation?.Invoke() ?? new OrgChart([], configured);
var members = new List<TeamMember>();
foreach (var agent in configured)
{
@@ -120,10 +147,54 @@ public sealed class TeamOverview(
var state = Classify(runs, approvals, last?.Status, typical, now);
members.Add(new TeamMember(
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds));
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds,
org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null));
}
return new TeamSnapshot(members, links, now);
return new TeamSnapshot(members, links, now, RollUp(org, members));
}
/// <summary>
/// How much a state needs someone — the higher, the sooner. A container takes the most
/// urgent state of everything inside it, so "somewhere in here" points at the right thing.
/// </summary>
public static int Urgency(TeamState state) => state switch
{
TeamState.Stuck => 5,
TeamState.Problem => 4,
TeamState.WaitingForApproval => 3,
TeamState.BudgetExhausted => 2,
TeamState.Working => 1,
_ => 0
};
/// <summary>
/// Every unit with the states of all agents in it and below. Pure, like
/// <see cref="Classify"/>: the page draws the numbers, it does not add them up.
/// </summary>
public static IReadOnlyList<TeamUnit> RollUp(OrgChart org, IReadOnlyList<TeamMember> members)
{
var result = new List<TeamUnit>();
foreach (var unit in org.Units.OrderBy(u => org.PathOf(u.Id), StringComparer.OrdinalIgnoreCase))
{
// Everyone whose unit is this one or lies below it. Ancestors() stops at a
// cycle, so a broken tree cannot hang the overview.
var inside = members
.Where(m => m.UnitId is { } id
&& (string.Equals(id, unit.Id, StringComparison.OrdinalIgnoreCase)
|| org.Ancestors(id).Any(a => string.Equals(a.Id, unit.Id, StringComparison.OrdinalIgnoreCase))))
.ToList();
var states = inside
.GroupBy(m => m.State)
.ToDictionary(g => g.Key, g => g.Count());
var worst = inside.Select(m => m.State).DefaultIfEmpty(TeamState.Idle).MaxBy(Urgency);
result.Add(new TeamUnit(
unit.Id, unit.Name, unit.ParentId, org.PathOf(unit.Id), org.LeaderOf(unit.Id)?.AgentId, unit.Goal,
inside.Count, states, inside.Sum(m => m.PendingApprovals), worst));
}
return result;
}
/// <summary>
+4 -1
View File
@@ -157,7 +157,10 @@ public sealed class BackupService
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = sourcePath,
Mode = SqliteOpenMode.ReadOnly
Mode = SqliteOpenMode.ReadOnly,
// Eine einmalige Kopie: Aus dem Pool heraus hielte die Verbindung die Datei
// danach offen, und unter Windows ließe sie sich nicht mehr ersetzen oder löschen.
Pooling = false
}.ToString();
using var conn = new SqliteConnection(connectionString);
@@ -10,6 +10,24 @@ public sealed class AgentConfig
[JsonPropertyName("displayName")]
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; }
/// <summary>
/// #683: The unit the agent left with its last move — so the lead it left is told as
/// well as the one it joined. <c>null</c> after the first placement.
/// </summary>
[JsonPropertyName("previousUnitId")]
public string? PreviousUnitId { get; set; }
[JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
@@ -172,6 +172,13 @@ public sealed class InstanceConfig
[JsonPropertyName("agents")]
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")]
public List<ServiceConfig> Services { get; set; } = new();
}
+72
View File
@@ -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; }
}
+64 -6
View File
@@ -44,6 +44,21 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#711: Who is working right now and who reached whom — for the team overview.</summary>
public Activity.AgentActivity Activity { get; } = new();
/// <summary>
/// #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.
/// </summary>
public Func<Organisation.OrgChart>? OrganisationProvider { get; set; }
private Organisation.OrgChart CurrentOrganisation()
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
/// <summary>The agent's system prompt with its organisation section (#683).</summary>
private string SystemPromptFor(AgentConfig agentConfig, bool hasTools)
=> Organisation.OrgBriefing.WithSection(
UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, hasTools),
Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime));
/// <summary>
/// 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<ChatMessage>();
// #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<ChatMessage>();
// #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;
}
/// <summary>
/// #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.
/// </summary>
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<AgentInfo> 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()
};
}
/// <summary>
@@ -0,0 +1,134 @@
using System.Text;
namespace ClawdDotNet.Core.Organisation;
/// <summary>
/// #683: The "Organisation" section of an agent's system prompt — its unit, its lead, whom it
/// can reach, and what changed recently.
///
/// <para><b>Why the code writes it and not the agent's own texts.</b> 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.</para>
///
/// <para><b>Without units there is no section</b>, so instances without a structure keep
/// exactly the prompt they had — and the prompt cache that goes with it.</para>
/// </summary>
public static class OrgBriefing
{
/// <summary>Starts the section; also how an existing chat context finds it to replace it.</summary>
public const string Heading = "# Organisation";
/// <summary>How long a move or a new colleague is pointed out.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -0,0 +1,324 @@
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 IReadOnlyCollection<AgentConfig> 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);
/// <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;
}
}
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Storage;
@@ -19,6 +20,9 @@ public sealed class SqliteStorage
private readonly string _connectionString;
private readonly SemaphoreSlim _writeGate = new(1, 1);
/// <summary>Every database opened in this process: full path → connection string.</summary>
private static readonly ConcurrentDictionary<string, string> OpenedDatabases = new(StringComparer.OrdinalIgnoreCase);
public string DatabasePath { get; }
public SqliteStorage(string databasePath)
@@ -37,6 +41,7 @@ public sealed class SqliteStorage
Pooling = true,
DefaultTimeout = 30
}.ToString();
OpenedDatabases[Path.GetFullPath(databasePath)] = _connectionString;
Initialize();
@@ -51,6 +56,33 @@ public sealed class SqliteStorage
}
}
/// <summary>
/// Closes the pooled connections of the databases at <paramref name="path"/> — a
/// database file, or a directory and everything below it — so the files can be deleted
/// or replaced.
///
/// <para>Not <see cref="SqliteConnection.ClearAllPools"/>: that is process-wide and also
/// takes the connections of every other database in use. The tests used it to clean up
/// their temporary databases, and a test running in parallel then lost its connection
/// while opening it — an ObjectDisposedException in <c>ApplyPragmas</c>, one in every
/// few full runs, always in a different test.</para>
/// </summary>
public static void ReleasePooledConnections(string path)
{
var full = Path.GetFullPath(path);
var below = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
foreach (var (database, connectionString) in OpenedDatabases)
{
if (!string.Equals(database, full, StringComparison.OrdinalIgnoreCase)
&& !database.StartsWith(below, StringComparison.OrdinalIgnoreCase))
continue;
using var connection = new SqliteConnection(connectionString);
SqliteConnection.ClearPool(connection);
}
}
public SqliteConnection OpenConnection()
{
var connection = new SqliteConnection(_connectionString);
@@ -34,4 +34,11 @@ public sealed record AgentToolContext(
/// Ohne Freigabestelle bleibt es <c>false</c>, und das Werkzeug prueft selbst.
/// </summary>
public bool FreigabeGeprueft { get; init; }
/// <summary>
/// #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. <c>null</c> in tests and outside the engine: no structure, no limit.
/// </summary>
public Organisation.OrgChart? Organisation { get; init; }
}
+119
View File
@@ -1791,7 +1791,126 @@ td.klein {
gap: 16px;
}
.team-eintrag {
text-align: left;
justify-content: flex-start;
width: 100%;
}
@media (max-width: 800px) {
.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; }
}
@@ -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(); });
@@ -68,8 +68,67 @@
"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.subtitle": "Wer arbeitet, wer hat wen in den letzten Sekunden erreicht und wo klemmt es. Einheiten zeigen, was in ihnen passiert; ein Klick öffnet sie oder zeigt Einzelheiten.",
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
"team.empty": "Diese Instanz hat keine Agenten.",
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
@@ -89,6 +148,17 @@
"team.openApprovals": "Freigaben öffnen",
"team.openChat": "Chat öffnen",
"team.openRuns": "Läufe anzeigen",
"team.unitAgentsOne": "1 Agent",
"team.unitAgents": "{n} Agenten",
"team.legendLead": "Leitung der Einheit",
"team.legendUnit": "Einheit, Klick öffnet sie",
"team.leads": "Leitet",
"team.memberOf": "Mitglied von",
"team.members": "Mitglieder",
"team.subUnits": "Untereinheiten",
"team.collapse": "Einheit zuklappen",
"team.expand": "Einheit aufklappen",
"team.openOrganisation": "Organisation bearbeiten",
"nav.services": "Dienste",
"nav.group.analytics": "Analyse",
"nav.usage": "Tokennutzung",
@@ -68,8 +68,67 @@
"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.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Units show what happens inside them; click to open them or to see details.",
"team.noInstance": "No instance selected. Choose an active instance in the header.",
"team.empty": "No agents in this instance.",
"team.legendLink": "reached (fades after 30 s)",
@@ -89,6 +148,17 @@
"team.openApprovals": "Open approvals",
"team.openChat": "Open chat",
"team.openRuns": "Show runs",
"team.unitAgentsOne": "1 agent",
"team.unitAgents": "{n} agents",
"team.legendLead": "lead of the unit",
"team.legendUnit": "unit, click to open",
"team.leads": "Leads",
"team.memberOf": "Member of",
"team.members": "Members",
"team.subUnits": "Sub-units",
"team.collapse": "Collapse unit",
"team.expand": "Expand unit",
"team.openOrganisation": "Edit organisation",
"nav.services": "Services",
"nav.group.analytics": "Analytics",
"nav.usage": "Token Usage",
@@ -61,6 +61,12 @@
</svg>
<span class="nav-label" data-i18n="nav.team">Team overview</span>
</a>
<a href="#/organisation" class="nav-link" title="Organisation" data-i18n-title="nav.organisation">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 2h6v5H9z M3 17h6v5H3z M15 17h6v5h-6z M12 7v5 M6 17v-5h12v5"/>
</svg>
<span class="nav-label" data-i18n="nav.organisation">Organisation</span>
</a>
<a href="#/aufgaben" class="nav-link" title="Tasks" data-i18n-title="nav.tasks">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
@@ -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() })
}))
])
];
}
+467 -87
View File
@@ -1,12 +1,14 @@
// ClawdDotNet — Team overview (#711)
//
// Who is working, who reached whom in the last seconds, and where something is stuck — at a
// glance, like looking across an office. Stage 0: the agents of the active instance and the
// live links between them. With units (#683) they become collapsible containers.
// glance, like looking across an office. Stage 0 drew the agents and the live links between
// them. Stage 1 (with #683) draws units as collapsible containers: a collapsed unit shows
// what happens inside it, so a problem three levels down is still visible from the top.
//
// Three rules from the concept (Organisation-Konzept, 10a):
// - The layout comes from the structure, not from the hand: no free node editor.
// - The state is decided by the instance (code), the page only draws it.
// - The state is decided by the instance (code), the page only draws it. That includes the
// rolled-up counts of a unit — the page does not add them up.
// - A click opens a side panel, not the chat — most problems are solved by an approval.
import { api, holeAktiveInstanz } from "../modules/api.js";
@@ -27,6 +29,15 @@ const LINK_VISIBLE_MS = 30_000;
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
const REFRESH_MS = 30_000;
/** Up to this many agents, every unit starts expanded; above it, only the top level shows. */
const EXPAND_ALL_UP_TO = 15;
/** Unit node ids are prefixed so a unit and an agent may share a name. */
const UNIT_PREFIX = "unit:";
/** Parent chains longer than this are a cycle — the instance reports it, the page must not hang. */
const MAX_DEPTH = 64;
// Every state has a colour AND a symbol: colour alone is not enough.
const STATES = {
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
@@ -40,7 +51,11 @@ const STATES = {
let librariesLoaded = null;
let cy = null;
let snapshot = null;
let selectedAgent = null;
let unitsById = new Map();
let membersById = new Map();
let expanded = null;
let structureKey = "";
let selected = null;
let panel = null;
let summary = null;
let unsubscribe = null;
@@ -49,6 +64,9 @@ let fadeTimer = null;
let refreshPending = null;
let resizeObserver = null;
/** Links as observed, agent to agent: "from->to" → { from, to, seenAt, count }. */
const links = new Map();
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
let layoutWidth = 0;
@@ -114,11 +132,14 @@ export async function renderTeam() {
}
canvas.replaceChildren();
indexSnapshot();
expanded = loadExpanded();
for (const link of snapshot.links ?? []) recordLink(link);
drawGraph(canvas);
unsubscribe = abonnieren("activity", onActivity);
refreshTimer = setInterval(refresh, REFRESH_MS);
fadeTimer = setInterval(fadeLinks, 1_000);
fadeTimer = setInterval(drawLinks, 1_000);
}
/** Called when the route changes: stop listening, stop timers, free the graph. */
@@ -133,7 +154,84 @@ export function leaveTeam() {
resizeObserver = null;
cy?.destroy();
cy = null;
selectedAgent = null;
selected = null;
structureKey = "";
layoutWidth = 0;
links.clear();
}
// ─── The tree ───
function indexSnapshot() {
unitsById = new Map((snapshot.units ?? []).map((u) => [u.id, u]));
membersById = new Map(snapshot.members.map((m) => [m.agentId, m]));
}
/** The unit and every unit above it, nearest first; stops at a cycle. */
function chain(unitId) {
const result = [];
for (let unit = unitsById.get(unitId); unit && result.length < MAX_DEPTH; unit = unitsById.get(unit.parentId)) {
if (result.includes(unit)) break;
result.push(unit);
}
return result;
}
/** Whether the unit and everything above it are expanded — then its content is drawn. */
function isOpen(unitId) {
return chain(unitId).every((u) => expanded.has(u.id));
}
/** Whether the unit itself is drawn, as a box or as a container. */
function isVisible(unit) {
return !unit.parentId || !unitsById.has(unit.parentId) || isOpen(unit.parentId);
}
/**
* Where an agent is drawn: itself if its unit is open, otherwise the topmost collapsed unit
* on its path. Links are drawn between these, so a collapsed unit collects the links of
* everyone inside it.
*/
function representative(agentId) {
const member = membersById.get(agentId);
if (!member) return null;
let rep = null;
for (const unit of chain(member.unitId)) {
if (!expanded.has(unit.id)) rep = unit.id;
}
return rep ? UNIT_PREFIX + rep : agentId;
}
function storageKey() {
return `clawd.team.expanded.${holeAktiveInstanz()}`;
}
function loadExpanded() {
try {
const stored = localStorage.getItem(storageKey());
if (stored) return new Set(JSON.parse(stored));
} catch {
// Private window or blocked storage: fall back to the default below.
}
// A small team is shown whole; a large one starts at the departments.
return snapshot.members.length <= EXPAND_ALL_UP_TO
? new Set(unitsById.keys())
: new Set();
}
function saveExpanded() {
try {
localStorage.setItem(storageKey(), JSON.stringify([...expanded]));
} catch {
// Only a convenience — the default applies next time.
}
}
function toggleUnit(unitId, open) {
if (open ?? !expanded.has(unitId)) expanded.add(unitId);
else expanded.delete(unitId);
saveExpanded();
applySnapshot();
}
// ─── Graph ───
@@ -143,8 +241,9 @@ function drawGraph(canvas) {
container: canvas,
elements: [],
wheelSensitivity: 0.2,
minZoom: 0.3,
maxZoom: 2.5,
minZoom: 0.2,
// A handful of collapsed units would otherwise fill the area with giant boxes.
maxZoom: 1.6,
// Nodes are not dragged: the layout comes from the structure, not the hand.
autoungrabify: true,
style: [
@@ -168,26 +267,74 @@ function drawGraph(canvas) {
}
},
{ selector: "node.aktiv", style: { "border-width": 4 } },
{ selector: "node.leitung", style: { "font-weight": "bold" } },
// A collapsed unit: a box that stands for everyone inside it.
{
selector: "node.einheit",
style: {
shape: "round-rectangle",
width: 210,
height: 70,
"border-width": 3,
"border-style": "double",
"background-color": token("--surface-subtil"),
"font-weight": "bold"
}
},
{ selector: "node.einheit.aktiv", style: { "border-width": 5 } },
// An expanded unit: a container around its members and sub-units.
{
selector: "node:parent",
style: {
shape: "round-rectangle",
padding: 20,
"border-width": 2,
"border-style": "dashed",
"background-color": token("--surface-subtil"),
"background-opacity": 0.5,
"text-valign": "top",
"text-halign": "center",
"text-margin-y": -4,
"text-max-width": 400,
"font-weight": "bold"
}
},
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
{
selector: "edge",
style: {
width: 3,
width: "data(width)",
"line-color": token("--accent"),
"target-arrow-color": token("--accent"),
"target-arrow-shape": "triangle",
"curve-style": "bezier",
opacity: "data(opacity)"
opacity: "data(opacity)",
label: "data(label)",
"font-size": 11,
color: token("--text"),
"text-background-color": token("--surface"),
"text-background-opacity": 1,
"text-background-padding": 2
}
}
]
});
cy.on("tap", "node", (event) => openPanel(event.target.id()));
cy.on("tap", "node", (event) => {
const id = event.target.id();
if (!id.startsWith(UNIT_PREFIX)) {
openPanel({ agent: id });
return;
}
const unitId = id.slice(UNIT_PREFIX.length);
// A collapsed unit opens on the first click — looking inside is what the click is for.
if (!expanded.has(unitId)) toggleUnit(unitId, true);
openPanel({ unit: unitId });
});
cy.on("dbltap", "node:parent", (event) => toggleUnit(event.target.id().slice(UNIT_PREFIX.length), false));
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
applySnapshot(true);
for (const link of snapshot.links ?? []) addLink(link);
applySnapshot();
observeSize(canvas);
}
@@ -207,58 +354,164 @@ function observeSize(canvas) {
resizeObserver.observe(canvas);
}
function runLayout() {
/** Counts layouts, so a layout overtaken by a newer one stops trying. */
let layoutGeneration = 0;
/** Runs one ELK layout with the given aspect ratio and returns the ratio it achieved. */
function layoutOnce(aspectRatio) {
return new Promise((resolve) => {
const layout = cy.layout({
name: "elk",
fit: false,
// The agents are packed evenly into the shape of the area, inside each container as
// on the top level. rectpacking keeps the order they were added in (units by path,
// the lead first), so a box does not jump around when a state changes.
elk: {
algorithm: "rectpacking",
"elk.spacing.nodeNode": 40,
"elk.aspectRatio": aspectRatio
},
// Room for the container's own padding and its label above the members.
nodeLayoutOptions: (node) => node.isParent()
? {
"elk.algorithm": "rectpacking",
"elk.padding": "[top=44,left=24,bottom=24,right=24]",
"elk.spacing.nodeNode": 24
}
: {}
});
// ELK computes asynchronously; measure once the positions are there.
layout.one("layoutstop", () => {
const box = cy?.elements().boundingBox();
resolve(box && box.h > 0 ? box.w / box.h : 1);
});
layout.run();
});
}
/**
* Lays the graph out in the shape of the area. rectpacking treats the aspect ratio as a
* hint and, with a few large containers on the top level, jumps between a handful of
* shapes — asked for 1.4 it may deliver 0.6 or 2.6. So a few hints are tried and the
* result closest to the area is kept; with the fit afterwards, that is the one that shows
* the boxes largest.
*/
async function runLayout() {
const area = cy.container();
layoutWidth = area.clientWidth;
const layout = cy.layout({
name: "elk",
fit: false,
// Stage 0 has no structure yet, so the agents are packed evenly into the shape of
// the area. rectpacking keeps their order, so a box does not jump around when a
// state changes. With units (#683) this becomes a layered layout of nested containers.
elk: {
algorithm: "rectpacking",
"elk.spacing.nodeNode": 40,
"elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0
? Math.max(0.5, area.clientWidth / area.clientHeight)
: 2
}
});
// ELK computes asynchronously; fit once the positions are there.
layout.one("layoutstop", () => cy?.fit(undefined, 30));
layout.run();
const target = area.clientWidth > 0 && area.clientHeight > 0
? Math.max(0.5, area.clientWidth / area.clientHeight)
: 2;
const generation = ++layoutGeneration;
const miss = (ratio) => Math.abs(Math.log(ratio / target));
let best = null;
for (const hint of [target, target * 1.2, target * 1.45, target * 0.8]) {
const ratio = await layoutOnce(hint);
if (!cy || generation !== layoutGeneration) return;
if (!best || miss(ratio) < best.miss) best = { hint, miss: miss(ratio), last: true };
else best.last = false;
if (best.miss < 0.15) break;
}
// The best one may not be the last one run.
if (!best.last) await layoutOnce(best.hint);
if (cy && generation === layoutGeneration) cy.fit(undefined, 30);
}
function nodeLabel(member) {
function agentLabel(member) {
const state = STATES[member.state] ?? STATES.Idle;
const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : "";
return `${state.symbol} ${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
const lead = member.isLead ? "★ " : "";
return `${state.symbol} ${lead}${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
}
function applySnapshot(relayout) {
if (!cy || !snapshot) return;
/** "5 agents · ▶ 2 · ⚠ 1" — everything that is not idle, most urgent first. */
function unitCounts(unit) {
const parts = [unit.agents === 1 ? t("team.unitAgentsOne") : t("team.unitAgents", { n: unit.agents })];
for (const state of ["Stuck", "Problem", "WaitingForApproval", "BudgetExhausted", "Working"]) {
const n = unit.states?.[state];
if (n) parts.push(`${STATES[state].symbol} ${n}`);
}
return parts.join(" · ");
}
const present = new Set();
for (const member of snapshot.members) {
present.add(member.agentId);
const state = STATES[member.state] ?? STATES.Idle;
const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) };
const node = cy.getElementById(member.agentId);
if (node.nonempty()) {
node.data(data);
} else {
cy.add({ group: "nodes", data });
relayout = true;
}
cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0);
function unitLabel(unit, open) {
return open
? `▾ ${unit.name} · ${unitCounts(unit)}`
: `▸ ${unit.name}\n${unitCounts(unit)}`;
}
/**
* The nodes as they should be now, parents before children and in a stable order: agents
* without a unit, then the units by path, each with its lead first.
*/
function desiredNodes() {
const nodes = [];
const byName = (a, b) => (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId);
const colour = (state) => token((STATES[state] ?? STATES.Idle).colour);
for (const member of snapshot.members.filter((m) => !m.unitId || !unitsById.has(m.unitId)).sort(byName)) {
nodes.push({ id: member.agentId, member, colour: colour(member.state) });
}
cy.nodes().filter((n) => !present.has(n.id())).remove();
for (const unit of snapshot.units ?? []) {
if (!isVisible(unit)) continue;
const open = expanded.has(unit.id);
const parent = unit.parentId && unitsById.has(unit.parentId) ? UNIT_PREFIX + unit.parentId : undefined;
nodes.push({ id: UNIT_PREFIX + unit.id, parent, unit, open, colour: colour(unit.worst) });
if (!open) continue;
if (relayout) runLayout();
const members = snapshot.members
.filter((m) => m.unitId === unit.id)
.sort((a, b) => (b.isLead - a.isLead) || byName(a, b));
for (const member of members) {
nodes.push({ id: member.agentId, parent: UNIT_PREFIX + unit.id, member, colour: colour(member.state) });
}
}
return nodes;
}
/**
* Brings the graph up to date. When only states changed, the nodes are updated in place and
* nothing moves. When the structure changed — a unit opened or closed, an agent moved — the
* nodes are rebuilt and laid out again: Cytoscape cannot turn a box into a container.
*/
function applySnapshot() {
if (!cy || !snapshot) return;
const nodes = desiredNodes();
const key = nodes.map((n) => `${n.id}<${n.parent ?? ""}${n.open ? "+" : ""}`).join("|");
const data = (n) => ({
id: n.id,
parent: n.parent,
colour: n.colour,
label: n.unit ? unitLabel(n.unit, n.open) : agentLabel(n.member)
});
if (key !== structureKey) {
structureKey = key;
cy.elements().remove();
cy.add(nodes.map((n) => ({ group: "nodes", data: data(n) })));
for (const n of nodes) decorate(n);
drawLinks();
runLayout();
} else {
for (const n of nodes) {
const { parent: _parent, ...rest } = data(n);
cy.getElementById(n.id).data(rest);
decorate(n);
}
}
renderSummary();
if (selectedAgent) openPanel(selectedAgent);
if (selected) openPanel(selected);
}
function decorate(n) {
const node = cy.getElementById(n.id);
node.toggleClass("einheit", Boolean(n.unit) && !n.open);
node.toggleClass("aktiv", Boolean(n.member?.runs.length) || Boolean(n.unit && !n.open && n.unit.states?.Working));
node.toggleClass("leitung", Boolean(n.member?.isLead));
}
// ─── Live ───
@@ -271,7 +524,10 @@ function forActiveInstance(data) {
function onActivity(data) {
if (!cy || !forActiveInstance(data)) return;
if (data.kind === "link" && data.link) addLink(data.link);
if (data.kind === "link" && data.link) {
recordLink(data.link);
drawLinks();
}
// Start and end change states; ask the instance instead of guessing here.
clearTimeout(refreshPending);
@@ -282,34 +538,65 @@ async function refresh() {
if (!cy) return;
try {
snapshot = await api("/team");
applySnapshot(false);
indexSnapshot();
applySnapshot();
} catch {
// The next event or tick tries again; a failed refresh keeps the last picture.
}
}
function addLink(link) {
if (!cy) return;
const age = Date.now() - new Date(link.atUtc).getTime();
if (age > LINK_VISIBLE_MS) return;
if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return;
// One edge per pair; a new contact renews it.
const id = `link:${link.from}->${link.to}`;
const existing = cy.getElementById(id);
const data = { id, source: link.from, target: link.to, seenAt: new Date(link.atUtc).getTime(), opacity: 1 };
if (existing.nonempty()) existing.data(data);
else cy.add({ group: "edges", data });
function recordLink(link) {
const seenAt = new Date(link.atUtc).getTime();
if (Date.now() - seenAt > LINK_VISIBLE_MS) return;
const key = `${link.from}->${link.to}`;
const known = links.get(key);
// A new contact renews the pair and counts; the snapshot may repeat one already seen.
if (known && known.seenAt >= seenAt) return;
links.set(key, { from: link.from, to: link.to, seenAt, count: (known?.count ?? 0) + 1 });
}
function fadeLinks() {
/**
* The edges between what is drawn: links of agents inside a collapsed unit start or end at
* that unit, and several such links become one edge with a counter. A link that stays
* inside one collapsed unit is not drawn — it is the unit talking to itself.
*/
function drawLinks() {
if (!cy) return;
const now = Date.now();
cy.edges().forEach((edge) => {
const age = now - edge.data("seenAt");
if (age > LINK_VISIBLE_MS) edge.remove();
else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS));
});
const bundles = new Map();
for (const [key, link] of links) {
const age = now - link.seenAt;
if (age > LINK_VISIBLE_MS) {
links.delete(key);
continue;
}
const from = representative(link.from);
const to = representative(link.to);
if (!from || !to || from === to) continue;
if (cy.getElementById(from).empty() || cy.getElementById(to).empty()) continue;
const id = `link:${from}->${to}`;
const bundle = bundles.get(id) ?? { id, source: from, target: to, seenAt: 0, count: 0 };
bundle.seenAt = Math.max(bundle.seenAt, link.seenAt);
bundle.count += link.count;
bundles.set(id, bundle);
}
cy.edges().filter((edge) => !bundles.has(edge.id())).remove();
for (const bundle of bundles.values()) {
const data = {
id: bundle.id,
source: bundle.source,
target: bundle.target,
opacity: Math.max(0.15, 1 - (now - bundle.seenAt) / LINK_VISIBLE_MS),
width: Math.min(8, 2 + bundle.count),
label: bundle.count > 1 ? `×${bundle.count}` : ""
};
const edge = cy.getElementById(bundle.id);
if (edge.nonempty()) edge.data(data);
else cy.add({ group: "edges", data });
}
}
// ─── Summary and legend ───
@@ -334,32 +621,56 @@ function buildLegend() {
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
...Object.entries(STATES).map(([state, s]) =>
erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })),
erstelleElement("span", { text: `★ ${t("team.legendLead")}` }),
erstelleElement("span", { text: `▸ ${t("team.legendUnit")}` }),
erstelleElement("span", { text: `→ ${t("team.legendLink")}` })
);
}
// ─── Side panel ───
function openPanel(agentId) {
const member = snapshot?.members.find((m) => m.agentId === agentId);
if (!member || !panel) return;
selectedAgent = agentId;
cy?.getElementById(agentId).select();
function openPanel(target) {
if (!panel || !snapshot) return;
const rows = target.unit ? unitRows(target.unit) : agentRows(target.agent);
if (!rows) return;
selected = target;
cy?.elements().unselect();
cy?.getElementById(target.unit ? UNIT_PREFIX + target.unit : target.agent).select();
panel.replaceChildren(...rows);
panel.hidden = false;
}
function panelHead(title) {
return erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
erstelleElement("h2", { text: title }),
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: "×",
title: t("common.close"),
onclick: closePanel
}));
}
function agentRows(agentId) {
const member = membersById.get(agentId);
if (!member) return null;
const state = STATES[member.state] ?? STATES.Idle;
const unit = unitsById.get(member.unitId);
const rows = [
erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
erstelleElement("h2", { text: member.displayName || member.agentId }),
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: "×",
title: t("common.close"),
onclick: closePanel
})),
panelHead(member.displayName || member.agentId),
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
];
if (unit) {
rows.push(erstelleElement("p", { klasse: "text-klein" },
`${member.isLead ? `★ ${t("team.leads")} ` : `${t("team.memberOf")} `}`,
unitButton(unit)));
}
// Top layer: what is happening now.
if (member.runs.length) {
rows.push(erstelleElement("h3", { text: t("team.running") }));
@@ -408,9 +719,78 @@ function openPanel(agentId) {
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
);
rows.push(actions);
return rows;
}
panel.replaceChildren(...rows);
panel.hidden = false;
function unitRows(unitId) {
const unit = unitsById.get(unitId);
if (!unit) return null;
const worst = STATES[unit.worst] ?? STATES.Idle;
const rows = [
panelHead(unit.name),
erstelleElement("p", { klasse: "gedaempft text-klein", text: unit.path }),
erstellePlakette(`${worst.symbol} ${unitCounts(unit)}`, worst.tone)
];
if (unit.goal) rows.push(erstelleElement("p", { klasse: "text-klein", text: unit.goal }));
if (unit.pendingApprovals > 0) {
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: unit.pendingApprovals })}` }));
}
// Direct members, the lead first; what is further down is one click on a sub-unit away.
rows.push(erstelleElement("h3", { text: t("team.members") }));
const members = snapshot.members
.filter((m) => m.unitId === unit.id)
.sort((a, b) => (b.isLead - a.isLead) || (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId));
if (!members.length) rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
for (const member of members) {
const state = STATES[member.state] ?? STATES.Idle;
rows.push(erstelleElement("button", {
type: "button",
klasse: "flach klein team-eintrag",
text: `${state.symbol} ${member.isLead ? "★ " : ""}${member.displayName || member.agentId} — ${t(`team.state.${member.state}`)}`,
onclick: () => openPanel({ agent: member.agentId })
}));
}
const children = (snapshot.units ?? []).filter((u) => u.parentId === unit.id);
if (children.length) {
rows.push(erstelleElement("h3", { text: t("team.subUnits") }));
for (const child of children) rows.push(erstelleElement("div", {}, unitButton(child)));
}
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
const open = expanded.has(unit.id);
actions.append(
erstelleElement("button", {
type: "button",
klasse: "primaer klein",
text: open ? t("team.collapse") : t("team.expand"),
onclick: () => toggleUnit(unit.id, !open)
}),
link("#/organisation", t("team.openOrganisation"))
);
rows.push(actions);
return rows;
}
/** A unit as a link in the panel, with its most urgent state. */
function unitButton(unit) {
const worst = STATES[unit.worst] ?? STATES.Idle;
return erstelleElement("button", {
type: "button",
klasse: "flach klein team-eintrag",
text: `${worst.symbol} ${unit.name} · ${unitCounts(unit)}`,
onclick: () => {
// Show it in the graph too: open everything above it.
for (const above of chain(unit.parentId)) expanded.add(above.id);
saveExpanded();
applySnapshot();
openPanel({ unit: unit.id });
}
});
}
function link(href, text, style = "flach") {
@@ -423,7 +803,7 @@ function link(href, text, style = "flach") {
}
function closePanel() {
selectedAgent = null;
selected = null;
cy?.elements().unselect();
if (panel) panel.hidden = true;
}
@@ -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 ───
/// <summary>
+27 -4
View File
@@ -97,7 +97,7 @@ public sealed record ServiceDto(
public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = "");
/// <summary>Ein neuer Agent.</summary>
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);
/// <summary>Eine Aufgabe vom Taskboard.</summary>
public sealed record TaskDto(
@@ -125,8 +125,29 @@ public sealed record TaskDto(
/// </summary>
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
/// <summary>#711: The team overview — every agent and who reached whom recently.</summary>
public sealed record TeamSnapshotDto(IReadOnlyList<TeamMemberDto> Members, IReadOnlyList<AgentLinkDto> Links, DateTime AtUtc);
/// <summary>#711: The team overview — every agent and unit, and who reached whom recently.</summary>
public sealed record TeamSnapshotDto(
IReadOnlyList<TeamMemberDto> Members,
IReadOnlyList<AgentLinkDto> Links,
DateTime AtUtc,
IReadOnlyList<TeamUnitDto> Units);
/// <summary>
/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
/// instance. <see cref="States"/> maps a state name to a count; <see cref="Worst"/> is the
/// most urgent state inside.
/// </summary>
public sealed record TeamUnitDto(
string Id,
string Name,
string? ParentId,
string Path,
string? LeaderAgentId,
string? Goal,
int Agents,
IReadOnlyDictionary<string, int> States,
int PendingApprovals,
string Worst);
/// <summary>
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
@@ -142,7 +163,9 @@ public sealed record TeamMemberDto(
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs);
long TypicalRunMs,
string? UnitId,
bool IsLead);
/// <summary>#711: A run in progress.</summary>
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
+7 -2
View File
@@ -74,9 +74,14 @@ internal static class Mapping
snapshot.Members.Select(m => new TeamMemberDto(
m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
m.Runs.Select(ToDto).ToList(),
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs)).ToList(),
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs,
m.UnitId, m.IsLead)).ToList(),
snapshot.RecentLinks.Select(ToDto).ToList(),
snapshot.AtUtc);
snapshot.AtUtc,
(snapshot.Units ?? []).Select(u => new TeamUnitDto(
u.Id, u.Name, u.ParentId, u.Path, u.LeaderAgentId, u.Goal, u.Agents,
u.States.ToDictionary(s => s.Key.ToString(), s => s.Value),
u.PendingApprovals, u.Worst.ToString())).ToList());
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
@@ -0,0 +1,63 @@
namespace ClawdDotNet.Web.Contracts;
/// <summary>#683: The organisation of an instance — units, members, and what is wrong with it.</summary>
public sealed record OrganisationDto(
IReadOnlyList<UnitDto> Units,
IReadOnlyList<OrgMemberDto> Agents,
IReadOnlyList<string> Problems,
IReadOnlyList<string> KnownTools);
public sealed record UnitDto(
string Id,
string Name,
string? ParentId,
string? LeaderAgentId,
string Goal,
IReadOnlyList<string>? ToolCeiling,
IReadOnlyList<string>? EffectiveToolCeiling,
UnitDefaultsDto Defaults,
string Path,
int Depth);
public sealed record UnitDefaultsDto(string? Model, decimal? DailyCostUsd, long? DailyTokens, string? WhenExhausted);
/// <summary>An agent as the organisation page shows it: where it is, whom it answers to.</summary>
public sealed record OrgMemberDto(
string AgentId,
string DisplayName,
string? UnitId,
bool IsLead,
string? EffectiveLeadId,
DateTime? UnitSinceUtc,
IReadOnlyList<string> 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 = "");
/// <summary>
/// A move or a ceiling change. With <see cref="Preview"/> nothing is written — the answer
/// says what would happen. <see cref="DropExcessTools"/> confirms removing the tools beyond a
/// ceiling that the preview named.
/// </summary>
public sealed record MoveInputDto(string? Target, string Reason = "", bool DropExcessTools = false, bool Preview = false);
public sealed record CeilingInputDto(IReadOnlyList<string>? Tools, string Reason = "", bool DropExcessTools = false, bool Preview = false);
public sealed record StructurePreviewDto(
IReadOnlyList<string> AffectedAgents,
IReadOnlyDictionary<string, IReadOnlyList<string>> ToolsLost,
IReadOnlyList<string> Running,
IReadOnlyList<string> 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<string, string> Values, string Reason = "", string? DefaultValue = null);
@@ -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());
@@ -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;
/// <summary>
/// #683: The organisation — units, leads, moves, tool ceilings, unit-wide values. Every
/// decision is made by <see cref="OrgAdministration"/>; this only translates.
/// </summary>
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<UnitSetting>(input.Setting, ignoreCase: true, out var setting)
|| !Enum.TryParse<UnitSettingMode>(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<UnitSetting>(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);
}
@@ -0,0 +1,252 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #683: Units in the administration — create, lead, move, limit, write unit-wide values.
/// </summary>
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<string, object?>()) };
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<string, string> { ["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<string, string> { ["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<string, string>(), "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");
}
}
@@ -28,7 +28,7 @@ public sealed class TaskAdministrationTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
try { Directory.Delete(_dir, recursive: true); }
catch { /* Aufraeumen ist Nebensache */ }
}
@@ -1,5 +1,7 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
@@ -79,4 +81,85 @@ public sealed class TeamOverviewTests
public void A_cancelled_run_is_not_a_problem()
// Cancelled means a human stopped it — that was a decision, not a fault.
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
// ═══════════════════ stage 1: units roll up ═══════════════════
/// <summary>marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top.</summary>
private static OrgChart Shop() => new(
[
new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
new UnitConfig { Id = "accounting", Name = "Accounting" }
],
[
new AgentConfig { AgentId = "ceo" },
new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" },
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
new AgentConfig { AgentId = "ads", UnitId = "amazon" },
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
]);
private static TeamMember Member(string id, string? unit, TeamState state, int approvals = 0)
=> new(id, id, "m", state, [], approvals, null, null, null, 0, unit);
[Fact]
public void A_problem_deep_down_shows_on_every_container_above_it()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("ceo", null, TeamState.Working),
Member("marketing-lead", "marketing", TeamState.Working),
Member("amazon-lead", "amazon", TeamState.Idle),
Member("ads", "amazon", TeamState.Problem),
Member("bookkeeper", "accounting", TeamState.Idle)
]);
var marketing = units.Single(u => u.Id == "marketing");
marketing.Agents.ShouldBe(3, "its own lead and the two in amazon below");
marketing.Worst.ShouldBe(TeamState.Problem);
marketing.States[TeamState.Working].ShouldBe(1);
marketing.States[TeamState.Problem].ShouldBe(1);
units.Single(u => u.Id == "amazon").Worst.ShouldBe(TeamState.Problem);
units.Single(u => u.Id == "accounting").Worst.ShouldBe(TeamState.Idle);
}
[Fact]
public void Open_approvals_are_added_up_along_the_tree()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("marketing-lead", "marketing", TeamState.WaitingForApproval, approvals: 1),
Member("ads", "amazon", TeamState.WaitingForApproval, approvals: 2)
]);
units.Single(u => u.Id == "marketing").PendingApprovals.ShouldBe(3);
units.Single(u => u.Id == "amazon").PendingApprovals.ShouldBe(2);
}
[Fact]
public void A_unit_knows_its_path_and_its_lead()
{
var amazon = TeamOverview.RollUp(Shop(), []).Single(u => u.Id == "amazon");
amazon.Path.ShouldBe("Marketing / Amazon");
amazon.LeaderAgentId.ShouldBe("amazon-lead");
amazon.Agents.ShouldBe(0);
amazon.Worst.ShouldBe(TeamState.Idle);
}
[Fact]
public void Stuck_is_the_most_urgent_and_idle_the_least()
{
var byUrgency = Enum.GetValues<TeamState>().OrderByDescending(TeamOverview.Urgency).ToList();
byUrgency.First().ShouldBe(TeamState.Stuck);
byUrgency.Last().ShouldBe(TeamState.Idle);
TeamOverview.Urgency(TeamState.Problem).ShouldBeGreaterThan(TeamOverview.Urgency(TeamState.WaitingForApproval));
}
[Fact]
public void Without_units_there_is_nothing_to_roll_up()
=> TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)])
.ShouldBeEmpty();
}
@@ -23,7 +23,7 @@ public sealed class AuditRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -35,7 +35,7 @@ public sealed class BackupServiceTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -95,7 +95,7 @@ public sealed class BackupServiceTests : IDisposable
}, default);
// Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft.
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_instanceDir);
}
private string ZipPath => Path.Combine(_root, "sicherung.zip");
@@ -40,7 +40,7 @@ public sealed class LicenseBackupTests : IDisposable
public void Dispose()
{
SecretKeyStore.UseDirectory(null);
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -25,7 +25,7 @@ public sealed class BudgetGuardTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -220,7 +220,7 @@ public sealed class UsageRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { }
}
@@ -116,25 +116,27 @@ public sealed class AgentEngineConcurrencyTests
{
// Wichtige Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten
// gegeneinander sperren.
var toolDelay = TimeSpan.FromMilliseconds(200);
var fixture = new EngineFixture().WithTool(FakeTool.Slow(toolDelay));
//
// Bis zum 2026-09-24 antwortete die Attrappe hier nur mit Text: Das langsame
// Werkzeug lief nie, die Läufe waren sofort fertig, und geprüft wurde nur, ob drei
// leere Läufe unter 450 ms bleiben. Das bestand auch mit gegenseitig gesperrten
// Agenten - und schlug unter Last fehl, weil es die Maschine mass statt der Engine.
// Jetzt ruft jeder Agent das Werkzeug wirklich auf, und bewiesen wird die Überlappung.
var tool = FakeTool.Slow(TimeSpan.FromMilliseconds(500));
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-1", "TestTool");
var b = fixture.AddAgent("agent-2", "TestTool");
var c = fixture.AddAgent("agent-3", "TestTool");
fixture.Client.AlwaysRespondsWithText("Antwort");
fixture.Client.RespondsContextually("TestTool");
var sw = System.Diagnostics.Stopwatch.StartNew();
await Task.WhenAll(
fixture.Engine.ChatAsync(a, "x", "test-instance", default),
fixture.Engine.ChatAsync(b, "x", "test-instance", default),
fixture.Engine.ChatAsync(c, "x", "test-instance", default));
sw.Stop();
// Seriell wären es mindestens 3 × 200 ms. Parallel deutlich weniger.
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(450),
"Agenten dürfen sich nicht gegenseitig blockieren");
tool.MaxConcurrent.ShouldBeGreaterThan(1, "Agenten dürfen sich nicht gegenseitig blockieren");
}
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 2_000)
@@ -29,7 +29,7 @@ public sealed class MemoryRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -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;
/// <summary>
/// #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.
/// </summary>
public sealed class AddressBookEnforcementTests
{
/// <summary>marketing (lead: marketing-lead) › amazon (lead: amazon-lead, ads); accounting: bookkeeper.</summary>
private static (EngineFixture Fixture, List<UnitConfig> Units) Shop()
{
var fixture = new EngineFixture();
var units = new List<UnitConfig>
{
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<UnitConfig>();
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.");
}
}
@@ -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();
}
@@ -23,7 +23,7 @@ public sealed class StagingRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -35,7 +35,7 @@ public sealed class StagingServiceTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -33,7 +33,7 @@ public sealed class WerkzeugrechteTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -0,0 +1,61 @@
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Storage;
/// <summary>
/// Releasing the pool of one database must leave every other database alone. The tests used
/// the process-wide ClearAllPools to clean up, and a test running in parallel lost its
/// connection in the middle of opening it — one red run in every few, always another test.
/// </summary>
public sealed class PoolReleaseTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "pool-" + Guid.NewGuid().ToString("N")[..8]);
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private static async Task TouchAsync(SqliteStorage storage)
=> await storage.WriteAsync(async connection =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES ('k', 'v')";
await cmd.ExecuteNonQueryAsync();
}, default);
[Fact]
public async Task Releasing_one_directory_frees_its_files_and_nothing_else()
{
var first = new SqliteStorage(Path.Combine(_root, "a", "state.db"));
var second = new SqliteStorage(Path.Combine(_root, "b", "state.db"));
await TouchAsync(first);
await TouchAsync(second);
SqliteStorage.ReleasePooledConnections(Path.Combine(_root, "a"));
// The released database can go — on Windows an open handle would prevent that.
Should.NotThrow(() => Directory.Delete(Path.Combine(_root, "a"), recursive: true));
// The other one keeps working, and so does its pool.
await TouchAsync(second);
await using var connection = await second.OpenConnectionAsync(default);
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM ToolState WHERE Key = 'k'";
(await cmd.ExecuteScalarAsync()).ShouldBe("v");
}
[Fact]
public async Task A_database_file_can_be_named_directly()
{
var path = Path.Combine(_root, "c", "state.db");
await TouchAsync(new SqliteStorage(path));
SqliteStorage.ReleasePooledConnections(path);
Should.NotThrow(() => File.Delete(path));
}
}
@@ -32,7 +32,7 @@ public sealed class BudgetMissTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
@@ -35,7 +35,7 @@ public sealed class CoordinationMigrationTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -30,7 +30,7 @@ public sealed class ScannerCapacityTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
@@ -27,7 +27,7 @@ public sealed class TaskRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -27,7 +27,7 @@ public sealed class TaskScannerTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -28,7 +28,7 @@ public sealed class TaskboardServiceTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -26,7 +26,7 @@ public sealed class PromptHistoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is best effort */ }
}
@@ -34,7 +34,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
public async ValueTask DisposeAsync()
{
await Task.CompletedTask;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
@@ -28,7 +28,7 @@ public sealed class MemoryToolTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
try { Directory.Delete(_dir, recursive: true); }
catch { /* Aufraeumen ist Nebensache */ }
}
@@ -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;
/// <summary>
/// #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.
/// </summary>
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()
{
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private Task<ToolResult> Run(string agentId, string json)
{
var context = new AgentToolContext(
agentId, "inst", new Dictionary<string, object?>(), 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();
}
}
@@ -35,7 +35,7 @@ public sealed class DublettenTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_verzeichnis);
try { Directory.Delete(_verzeichnis, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}