- WorkspaceConfig in der Instanzkonfiguration: Id, Name, Zweck, Eigentuemer-Einheit, Mitglieder (Einheit oder Agent, Stufe Read/Write/ Manage, Untereinheiten ein/aus), geschuetzte Pfade. Das Recht steht am Bereich, nicht am Agenten. - OrgChart: AccessTo, WorkspacesFor, UnitWorkspace; Problems nennt ungueltige Ids und tote Verweise. WorkspaceLayout: Bereiche liegen unter Agents/SharedWorkspaces/<id>/, neben dem unveraenderten SharedWorkspace. - FileRW: workspace ist ein Name - personal, shared (wie bisher ueber sharedAccessLevel), unit (eigene Einheit) oder eine Bereichs-Id. Ein unbekannter und ein nicht erlaubter Bereich bekommen dieselbe Antwort. Loeschen braucht Manage wie frueher Admin. Der #662-Hinweis sucht in allen erlaubten Bereichen. - Mit Einheiten ist shared:/tasks/ fuer FileRW gesperrt: Aufgaben laufen ueber das Brett, sonst umginge eine direkt geschriebene Datei Brett- und Adressbuchpruefung. Ohne Einheiten unveraendert. - Organisationsabschnitt im Prompt nennt die Bereiche mit Stufe und Zweck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
7.5 KiB
C#
167 lines
7.5 KiB
C#
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)}");
|
|
}
|
|
|
|
AppendWorkspaces(sb, org, agentId);
|
|
|
|
sb.Append("Agents not named here cannot be reached from your position — go through your lead.");
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// #684: The shared workspaces the agent may use. FileRW's own description cannot name
|
|
/// them — it is the same for every agent — so they are listed here, where the agent's
|
|
/// position is described anyway, and change with it when it moves.
|
|
/// </summary>
|
|
private static void AppendWorkspaces(StringBuilder sb, OrgChart org, string agentId)
|
|
{
|
|
var grants = org.WorkspacesFor(agentId);
|
|
if (grants.Count == 0)
|
|
return;
|
|
|
|
sb.AppendLine("Your shared workspaces (FileRW, parameter \"workspace\"):");
|
|
foreach (var grant in grants)
|
|
{
|
|
var workspace = grant.Workspace;
|
|
var name = grant.IsOwnUnit
|
|
? $"\"{WorkspaceLayout.Unit}\" — your unit's workspace (also \"{workspace.Id}\")"
|
|
: $"\"{workspace.Id}\"" + (string.IsNullOrWhiteSpace(workspace.Name) || workspace.Name == workspace.Id ? "" : $" ({workspace.Name})");
|
|
var access = grant.Access switch
|
|
{
|
|
Config.WorkspaceAccess.Read => "read only",
|
|
Config.WorkspaceAccess.Write => "read and write",
|
|
_ => "read, write and delete"
|
|
};
|
|
sb.AppendLine($"- {name}, {access}{Describe(workspace.Purpose)}");
|
|
}
|
|
if (grants.Any(g => g.IsOwnUnit))
|
|
sb.AppendLine("What your unit works on belongs in \"unit\", where your colleagues find it; \"personal\" is yours alone.");
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|