Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ecf2d5ca | ||
|
|
68a432fec6 | ||
|
|
626fc8d593 | ||
|
|
d014a20c38 | ||
|
|
dc11961805 |
@@ -214,6 +214,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 +568,12 @@ public sealed class AppHost : IAsyncDisposable
|
||||
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
|
||||
});
|
||||
|
||||
// #683: The organisation tree, fresh from the configuration on every question, so a
|
||||
// change made through the administration applies to the next run without a restart.
|
||||
engine.OrganisationProvider = () => Core.Organisation.OrgChart.From(instance);
|
||||
foreach (var problem in Core.Organisation.OrgChart.From(instance).Problems())
|
||||
logger.LogWarning("Organisation: {Problem}", problem);
|
||||
|
||||
engine.LoadPersistedChats();
|
||||
|
||||
var (scanner, staging, board) = BuildTaskboard(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -1795,3 +1795,116 @@ td.klein {
|
||||
.team-flaeche { flex-direction: column; }
|
||||
.team-seitenleiste { width: auto; }
|
||||
}
|
||||
|
||||
/* ─── Organisation (#683) ─── */
|
||||
|
||||
.org-ansicht {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.org-raster {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 360px) 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.org-baum {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--divider);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.org-baum-kopf {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.org-knoten-liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.org-ebene-titel {
|
||||
padding: 6px 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
button.org-knoten {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
background: transparent;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button.org-knoten.aktiv {
|
||||
border-left-color: var(--accent);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
button.org-einheit .org-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.org-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.org-detail .karte {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.org-grund input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.org-werkzeuge {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 4px 12px;
|
||||
}
|
||||
|
||||
.org-werkzeug {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.org-einstellung {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.org-liste {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
tr.org-ausreisser td {
|
||||
background: rgba(181, 130, 42, 0.08);
|
||||
}
|
||||
|
||||
.org-probleme {
|
||||
border: 1px solid var(--gefahr);
|
||||
border-left-width: 4px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.org-probleme ul {
|
||||
margin: 6px 0 0 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.org-raster { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -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,6 +68,65 @@
|
||||
"nav.agents": "Agenten",
|
||||
"nav.tasks": "Aufgaben",
|
||||
"nav.team": "Teamübersicht",
|
||||
"common.saved": "Gespeichert.",
|
||||
"common.create": "Anlegen",
|
||||
"nav.organisation": "Organisation",
|
||||
"org.title": "Organisation",
|
||||
"org.subtitle": "Einheiten, Leitungen und Mitglieder. Jeder Agent erreicht nur seine Einheit und seine Leitung — die Struktur ist das Adressbuch.",
|
||||
"org.problems": "Probleme in der Struktur",
|
||||
"org.structure": "Struktur",
|
||||
"org.newUnit": "Neue Einheit",
|
||||
"org.newSubUnit": "Neue Untereinheit",
|
||||
"org.topLevel": "Oberste Ebene",
|
||||
"org.leadShort": "Leitung",
|
||||
"org.unitId": "Kennung (Buchstaben, Ziffern, - und _)",
|
||||
"org.unitName": "Name",
|
||||
"org.parent": "Übergeordnete Einheit",
|
||||
"org.goal": "Ziel der Einheit",
|
||||
"org.reason": "Begründung der Änderung (geht ins Audit)",
|
||||
"org.reasonPlaceholder": "z. B. Marktplätze bekommen ein eigenes Team",
|
||||
"org.selectHint": "Links eine Einheit oder einen Agenten wählen.",
|
||||
"org.lead": "Leitung",
|
||||
"org.noLead": "— keine Leitung (die Leitung darüber übernimmt) —",
|
||||
"org.leadHint": "Die Leitung muss Mitglied der Einheit sein.",
|
||||
"org.position": "Platz im Baum",
|
||||
"org.move": "Verschieben",
|
||||
"org.moveUnitHint": "Die Mitglieder bleiben in der Einheit; es ändert sich, was darüber liegt. Nicht möglich, solange ein betroffener Agent läuft.",
|
||||
"org.ceiling": "Rechte-Obergrenze",
|
||||
"org.ceilingHint": "Was die Einheit nicht darf, kann ein Mitglied nicht bekommen. Senken entzieht den Mitgliedern Werkzeuge — nach einer Bestätigung.",
|
||||
"org.ceilingLimit": "Werkzeuge dieser Einheit begrenzen",
|
||||
"org.ceilingInherited": "Von oben begrenzt auf: {tools}",
|
||||
"org.settings": "Sammelwerte",
|
||||
"org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.",
|
||||
"org.setting.DailyCostUsd": "Tagesbudget (USD)",
|
||||
"org.setting.DailyTokens": "Tagesbudget (Tokens)",
|
||||
"org.setting.Model": "Modell",
|
||||
"org.setting.WhenExhausted": "Wenn das Budget erschöpft ist",
|
||||
"org.modePerAgent": "jeder Agent bekommt den Wert",
|
||||
"org.modeDistribute": "Wert ist ein Gesamtbetrag, verteilt",
|
||||
"org.preview": "Vorschau",
|
||||
"org.apply": "Gewählte Werte schreiben",
|
||||
"org.agent": "Agent",
|
||||
"org.current": "Bisher",
|
||||
"org.proposed": "Neu",
|
||||
"org.outlier": "Ausreißer",
|
||||
"org.outlierHint": "Ausreißer weichen stark von ihren Kollegen ab — oft ein Spezialist mit bewusst anderem Wert. Sie sind standardmäßig nicht gewählt.",
|
||||
"org.rememberDefault": "Als Vorgabe für neue Mitglieder merken",
|
||||
"org.settingInvalid": "Dieser Wert ist für diese Einstellung nicht gültig.",
|
||||
"org.members": "Mitglieder",
|
||||
"org.noMembers": "Noch keine Mitglieder.",
|
||||
"org.deleteUnit": "Einheit löschen",
|
||||
"org.unit": "Einheit",
|
||||
"org.answersTo": "Berichtet an",
|
||||
"org.human": "einen Menschen",
|
||||
"org.since": "In dieser Einheit seit {time}",
|
||||
"org.tools": "Werkzeuge",
|
||||
"org.moveAgent": "Agent verschieben",
|
||||
"org.moveAgentHint": "Adressbuch, Leitung und womöglich Werkzeuge ändern sich. Der Agent und beide Leitungen erfahren es in ihrem nächsten Lauf.",
|
||||
"org.toolsLostTitle": "Werkzeuge würden entfernt",
|
||||
"org.toolsLostIntro": "Der neue Platz hat eine Rechte-Obergrenze. Diese Agenten verlören Werkzeuge:",
|
||||
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
|
||||
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
|
||||
"team.title": "Teamübersicht",
|
||||
"team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.",
|
||||
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
|
||||
|
||||
@@ -68,6 +68,65 @@
|
||||
"nav.agents": "Agents",
|
||||
"nav.tasks": "Tasks",
|
||||
"nav.team": "Team overview",
|
||||
"common.saved": "Saved.",
|
||||
"common.create": "Create",
|
||||
"nav.organisation": "Organisation",
|
||||
"org.title": "Organisation",
|
||||
"org.subtitle": "Units, leads and members. Every agent only reaches its unit and its lead — the structure is the address book.",
|
||||
"org.problems": "Problems in the structure",
|
||||
"org.structure": "Structure",
|
||||
"org.newUnit": "New unit",
|
||||
"org.newSubUnit": "New sub-unit",
|
||||
"org.topLevel": "Top level",
|
||||
"org.leadShort": "lead",
|
||||
"org.unitId": "Id (letters, digits, - and _)",
|
||||
"org.unitName": "Name",
|
||||
"org.parent": "Parent unit",
|
||||
"org.goal": "Goal of the unit",
|
||||
"org.reason": "Reason for the change (goes into the audit log)",
|
||||
"org.reasonPlaceholder": "e.g. marketplaces get their own team",
|
||||
"org.selectHint": "Select a unit or an agent on the left.",
|
||||
"org.lead": "Lead",
|
||||
"org.noLead": "— no lead (the lead above takes over) —",
|
||||
"org.leadHint": "The lead must be a member of the unit.",
|
||||
"org.position": "Position in the tree",
|
||||
"org.move": "Move",
|
||||
"org.moveUnitHint": "The members stay in the unit; what changes is what lies above it. Not possible while an affected agent is running.",
|
||||
"org.ceiling": "Tool ceiling",
|
||||
"org.ceilingHint": "What the unit may not have, a member cannot be granted. Lowering it removes tools from the members — after a confirmation.",
|
||||
"org.ceilingLimit": "Limit the tools of this unit",
|
||||
"org.ceilingInherited": "Limited from above to: {tools}",
|
||||
"org.settings": "Unit-wide values",
|
||||
"org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.",
|
||||
"org.setting.DailyCostUsd": "Daily budget (USD)",
|
||||
"org.setting.DailyTokens": "Daily budget (tokens)",
|
||||
"org.setting.Model": "Model",
|
||||
"org.setting.WhenExhausted": "When the budget is exhausted",
|
||||
"org.modePerAgent": "each agent gets the value",
|
||||
"org.modeDistribute": "value is a total, distributed",
|
||||
"org.preview": "Preview",
|
||||
"org.apply": "Write the selected values",
|
||||
"org.agent": "Agent",
|
||||
"org.current": "Current",
|
||||
"org.proposed": "New",
|
||||
"org.outlier": "outlier",
|
||||
"org.outlierHint": "Outliers differ strongly from their colleagues — often a specialist with a deliberately different value. They are not selected by default.",
|
||||
"org.rememberDefault": "Remember as default for new members",
|
||||
"org.settingInvalid": "That value is not valid for this setting.",
|
||||
"org.members": "Members",
|
||||
"org.noMembers": "No members yet.",
|
||||
"org.deleteUnit": "Delete unit",
|
||||
"org.unit": "Unit",
|
||||
"org.answersTo": "Answers to",
|
||||
"org.human": "a human",
|
||||
"org.since": "In this unit since {time}",
|
||||
"org.tools": "Tools",
|
||||
"org.moveAgent": "Move agent",
|
||||
"org.moveAgentHint": "Its address book, its lead and possibly its tools change. The agent and both leads are told in their next run.",
|
||||
"org.toolsLostTitle": "Tools would be removed",
|
||||
"org.toolsLostIntro": "The new position has a tool ceiling. These agents would lose tools:",
|
||||
"org.toolsLostConfirm": "Remove the tools and apply",
|
||||
"org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
|
||||
"team.title": "Team overview",
|
||||
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.",
|
||||
"team.noInstance": "No instance selected. Choose an active instance in the header.",
|
||||
|
||||
@@ -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() })
|
||||
}))
|
||||
])
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user