Einheiten, Stufe 2: das Adressbuch durchsetzen und jedem Agenten seinen Platz sagen (#683)

Durchgesetzt vom Code, nicht nur im Prompt beschrieben:
- Nachricht und Spawn an einen Agenten ausserhalb des Adressbuchs werden vor
  jedem Modellaufruf abgelehnt; die Antwort nennt den offenen Weg (die eigene
  Leitung, sonst @human).
- list_agents zeigt nur noch das Adressbuch.
- task_create und task_update pruefen die Zuweisung an einen Agenten gegen
  dasselbe Adressbuch; sonst liesse es sich ueber eine Aufgabe umgehen.
- Die Delegationstiefe folgt der Tiefe des Baums (Tiefe + 1), der
  eingestellte Wert bleibt Untergrenze.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 12:43:10 +02:00
co-authored by Claude Opus 5.5
parent d014a20c38
commit 626fc8d593
7 changed files with 503 additions and 6 deletions
+6
View File
@@ -565,6 +565,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(
+64 -6
View File
@@ -44,6 +44,21 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#711: Who is working right now and who reached whom — for the team overview.</summary>
public Activity.AgentActivity Activity { get; } = new();
/// <summary>
/// #683: The organisation tree, built fresh from the configuration on each call. Without
/// it, the instance has no structure and every agent reaches every other, as before.
/// </summary>
public Func<Organisation.OrgChart>? OrganisationProvider { get; set; }
private Organisation.OrgChart CurrentOrganisation()
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
/// <summary>The agent's system prompt with its organisation section (#683).</summary>
private string SystemPromptFor(AgentConfig agentConfig, bool hasTools)
=> Organisation.OrgBriefing.WithSection(
UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, hasTools),
Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime));
/// <summary>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
@@ -226,7 +241,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var messages = new List<ChatMessage>();
// #193: Die Regel zu gerahmten Werkzeugergebnissen gehoert zum festen Teil.
var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0);
// #683: dazu der Abschnitt zur Organisation.
var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0);
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
@@ -556,11 +572,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
messages = new List<ChatMessage>();
// #193 - siehe RunCoreAsync. Bestehende Verlaeufe behalten ihren
// Systemprompt; die Regel kommt mit dem naechsten neuen Kontext.
var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0);
var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0);
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
_chatContexts[agentConfig.AgentId] = messages;
}
else if (messages.Count > 0 && messages[0].Role == "system")
{
// #683: Nur der Organisationsabschnitt folgt der Struktur — nach einem
// Umzug weiss der Agent sonst im laufenden Chat nichts davon. Der Rest
// des bestehenden Systemprompts bleibt, wie er war (#193).
var section = Organisation.OrgBriefing.For(
CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime);
var refreshed = Organisation.OrgBriefing.WithSection(messages[0].Content ?? "", section);
if (refreshed != messages[0].Content)
messages[0] = ChatMessage.System(refreshed);
}
}
// Routing-Hinweis: Dem Agenten mitteilen, woher die Nachricht kommt
@@ -1052,19 +1079,41 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
$"Agent '{toAgentId}' wartet in dieser Kette bereits auf eine Antwort und " +
"kann keinen zweiten Auftrag annehmen, ohne sich selbst zu blockieren.";
if (callerChain.Depth >= MaxDelegationDepth)
// #683: Die Grenze folgt der Tiefe des Baums; der eingestellte Wert bleibt Untergrenze.
var maxDepth = CurrentOrganisation().DelegationDepth(MaxDelegationDepth);
if (callerChain.Depth >= maxDepth)
return $"Delegationstiefe erschöpft: {callerChain} hat die Grenze von " +
$"{MaxDelegationDepth} Weitergaben erreicht. Erledige den Rest selbst " +
$"{maxDepth} Weitergaben erreicht. Erledige den Rest selbst " +
"oder gib die Aufgabe an den Auftraggeber zurück.";
return null;
}
/// <summary>
/// #683: The address book, enforced — not merely described in the prompt. A refused
/// contact is an answer to the caller, naming the way that is open: its lead.
/// </summary>
private string? CheckAddressBook(string fromAgentId, string toAgentId)
{
var org = CurrentOrganisation();
if (org.CanAddress(fromAgentId, toAgentId))
return null;
var lead = org.EffectiveLeaderOf(fromAgentId);
return $"'{toAgentId}' is not in your address book: from your position in the organisation you " +
"cannot reach this agent directly. " +
(lead is not null
? $"Hand the matter to your lead '{lead.AgentId}', who can pass it on."
: "Create a task for @human if it needs someone outside your reach.");
}
public IReadOnlyList<AgentInfo> ListAgents(string callerAgentId)
{
var configs = _agentConfigProvider?.Invoke() ?? [];
// #683: Only the agent's address book — the cut of the organisation.
var reachable = CurrentOrganisation().AddressBook(callerAgentId);
return configs
.Where(a => a.AgentId != callerAgentId)
.Where(a => a.AgentId != callerAgentId && reachable.Contains(a.AgentId))
.Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId)))
.ToList();
}
@@ -1097,6 +1146,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
if (targetConfig is null)
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
if (CheckAddressBook(fromAgentId, toAgentId) is { } outOfReach)
return new AgentMessageResult(false, null, outOfReach);
if (CheckDelegation(chain, fromAgentId, toAgentId) is { } refused)
return new AgentMessageResult(false, null, refused);
@@ -1149,6 +1201,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// Auch der Spawn zählt: Er startet einen vollen Lauf und kann seinerseits
// delegieren. Ohne diese Prüfung wäre die Kette über spawn zu umgehen.
if (CheckAddressBook(fromAgentId, targetAgentId) is { } outOfReach)
return new AgentSpawnResult(false, targetAgentId, null, outOfReach);
if (CheckDelegation(chain, fromAgentId, targetAgentId) is { } refused)
return new AgentSpawnResult(false, targetAgentId, null, refused);
@@ -1606,7 +1661,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
this,
_memoryRepository,
_taskRepository,
chain ?? DelegationChain.Root(agentConfig.AgentId));
chain ?? DelegationChain.Root(agentConfig.AgentId))
{
Organisation = CurrentOrganisation()
};
}
/// <summary>
@@ -0,0 +1,124 @@
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}")) + ".");
}
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);
}
}
@@ -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; }
}
@@ -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>