Doctrine: die Leitung steuert ihre Einheit, ohne jeden Auftragstext anzufassen (#698)

- SqliteDoctrineStore: je Einheit versioniert (Tabelle Doctrines: Text, Autor, Lauf, Grund),
  hoechstens 2000 Zeichen, leer entfernt; im Speicher gehalten fuer jeden Lauf
- OrgBriefing: die Doctrine der eigenen Einheit, nur eine Ebene tief, mit dem Satz,
  dass ein Widerspruch zur Aufgabe eskaliert wird; Leitungen erfahren doctrine_set
- Escalation: doctrine_read, doctrine_set (nur die Leitung, Grund Pflicht)
- IAgentTool.Vorgabe(operation, context): Einstufung mit dem Aufrufkontext; eine Leitung
  der Fremdinhalt-Stufe legt doctrine_set zur Freigabe vor
- API GET/PUT /organisation/units/{id}/doctrine; WebUI-Karte mit Verlauf und Markierung
  haeufiger Aenderungen; i18n en/de
- Tests: DoctrineTests (5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 09:38:44 +02:00
co-authored by Claude Opus 5.5
parent c608af1abb
commit 679333b382
15 changed files with 395 additions and 6 deletions
+9 -3
View File
@@ -54,6 +54,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context.</summary>
public Cases.ICaseDesk? CaseDesk { get; set; }
/// <summary>#698: The units' doctrines; set by the host.</summary>
public Organisation.IDoctrineStore? Doctrines { get; set; }
/// <summary>#687: Approvals by leads within their unit's limits; set by the host.</summary>
public Staging.ILeadApprovals? LeadApprovals { get; set; }
@@ -86,7 +89,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
private string SystemPromptFor(AgentConfig agentConfig, bool hasTools)
=> Organisation.OrgBriefing.WithSection(
UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, hasTools),
Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime, TrustOf(agentConfig), CanEscalate, IsTop(agentConfig)));
Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime, TrustOf(agentConfig), CanEscalate, IsTop(agentConfig), DoctrineOf));
/// <summary>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
@@ -693,7 +696,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// 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, TrustOf(agentConfig), CanEscalate, IsTop(agentConfig));
CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime, TrustOf(agentConfig), CanEscalate, IsTop(agentConfig), DoctrineOf);
var refreshed = Organisation.OrgBriefing.WithSection(messages[0].Content ?? "", section);
if (refreshed != messages[0].Content)
messages[0] = ChatMessage.System(refreshed);
@@ -1227,6 +1230,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#689: Whether agents can raise a case — the desk is set up and its tool registered.</summary>
private bool CanEscalate => CaseDesk is not null && _toolRegistry.Get(Cases.CaseDesk.ToolName) is not null;
private Organisation.Doctrine? DoctrineOf(string unitId) => Doctrines?.Current(unitId);
/// <summary>#690: Whether the agent is the deciding instance at the top.</summary>
private bool IsTop(AgentConfig agent)
=> CanEscalate && string.Equals(CaseDesk!.TopAgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase);
@@ -1646,7 +1651,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct,
operation,
Staging.Werkzeugrechte.Aus(context.ToolConfig),
operation is null ? null : tool.Vorgabe(operation, context.ToolConfig));
operation is null ? null : tool.Vorgabe(operation, context));
if (intercept.Outcome == Staging.StagingOutcome.Denied)
{
@@ -1868,6 +1873,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
Cases = CaseDesk,
Approvals = LeadApprovals,
Doctrines = Doctrines,
SpecialBudgets = SpecialBudgets,
Sandbox = Sandbox,
Files = Files,
@@ -0,0 +1,93 @@
using System.Collections.Concurrent;
using System.Globalization;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Organisation;
/// <summary>One version of a unit's doctrine — who set it, when, in which run, and why.</summary>
public sealed record Doctrine(string UnitId, int Version, string Text, string SetBy, DateTime SetAt, string? RunId, string Reason)
{
public bool IsEmpty => string.IsNullOrWhiteSpace(Text);
}
public interface IDoctrineStore
{
/// <summary>The doctrine that holds now; <c>null</c> when the unit has none. Read on every run — kept in memory.</summary>
Doctrine? Current(string unitId);
Task<Doctrine> SetAsync(string unitId, string text, string setBy, string? runId, string reason, CancellationToken ct);
/// <summary>Every version, newest first.</summary>
Task<IReadOnlyList<Doctrine>> HistoryAsync(string unitId, CancellationToken ct);
}
/// <summary>
/// #698: What holds in a unit right now — a third text beside Identity (who) and Soul (how),
/// set by the unit's lead without asking (Organisation-Konzept, section 8).
///
/// <para><b>One per unit, one level deep.</b> It reaches the members of its unit and stops
/// there: a team below has its own lead, who writes its own. Five levels would otherwise put
/// five doctrines in every prompt, and nobody could tell which rule comes from where.</para>
///
/// <para><b>Short and capped</b> (<see cref="MaxLength"/>): it goes into every run of every
/// member. <b>Seldom and visible:</b> every version is kept with its author, run and reason —
/// frequent changes are a finding, observed rather than prevented.</para>
/// </summary>
public sealed class SqliteDoctrineStore(SqliteStorage storage) : IDoctrineStore
{
/// <summary>Five agents, forty runs a day, a thousand tokens each — two hundred thousand tokens for one text. So: short.</summary>
public const int MaxLength = 2_000;
private readonly ConcurrentDictionary<string, Doctrine?> _current = new(StringComparer.OrdinalIgnoreCase);
public Doctrine? Current(string unitId)
=> _current.GetOrAdd(unitId, id => LatestAsync(id, CancellationToken.None).GetAwaiter().GetResult());
public async Task<Doctrine> SetAsync(string unitId, string text, string setBy, string? runId, string reason, CancellationToken ct)
{
text = (text ?? "").Trim();
if (text.Length > MaxLength)
throw new ArgumentException($"A doctrine has at most {MaxLength} characters; this one has {text.Length}.");
var doctrine = await storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Doctrines (UnitId, Version, Text, SetBy, SetAt, RunId, Reason)
VALUES (@unit, COALESCE((SELECT MAX(Version) FROM Doctrines WHERE UnitId = @unit), 0) + 1,
@text, @by, @at, @run, @reason)
RETURNING Version
""";
var at = DateTime.UtcNow;
cmd.Parameters.AddWithValue("@unit", unitId);
cmd.Parameters.AddWithValue("@text", text);
cmd.Parameters.AddWithValue("@by", setBy);
cmd.Parameters.AddWithValue("@at", at.ToString("O"));
cmd.Parameters.AddWithValue("@run", (object?)runId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@reason", reason ?? "");
var version = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
return new Doctrine(unitId, version, text, setBy, at, runId, reason ?? "");
}, ct);
_current[unitId] = doctrine.IsEmpty ? null : doctrine;
return doctrine;
}
public async Task<IReadOnlyList<Doctrine>> HistoryAsync(string unitId, CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT UnitId, Version, Text, SetBy, SetAt, RunId, Reason FROM Doctrines WHERE UnitId = @unit ORDER BY Version DESC";
cmd.Parameters.AddWithValue("@unit", unitId);
var result = new List<Doctrine>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
result.Add(new Doctrine(reader.GetString(0), reader.GetInt32(1), reader.GetString(2), reader.GetString(3),
DateTime.Parse(reader.GetString(4), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
reader.IsDBNull(5) ? null : reader.GetString(5), reader.GetString(6)));
return result;
}
private async Task<Doctrine?> LatestAsync(string unitId, CancellationToken ct)
=> (await HistoryAsync(unitId, ct)).FirstOrDefault() is { IsEmpty: false } latest ? latest : null;
}
@@ -27,7 +27,9 @@ public static class OrgBriefing
/// <param name="atTop">#690: The agent is the deciding instance at the top — it gets that
/// section instead, with or without units.</param>
public static string? For(OrgChart org, string agentId, DateTime nowUtc, AgentTrust? trust = null, bool canEscalate = false, bool atTop = false)
/// <param name="doctrine">#698: The doctrine of a unit, if it has one.</param>
public static string? For(OrgChart org, string agentId, DateTime nowUtc, AgentTrust? trust = null, bool canEscalate = false, bool atTop = false,
Func<string, Doctrine?>? doctrine = null)
{
if (atTop && org.Agent(agentId) is not null)
return ForTop(org);
@@ -55,6 +57,14 @@ public static class OrgBriefing
if (!string.IsNullOrWhiteSpace(unit.Goal))
sb.AppendLine($"Goal of your unit: {unit.Goal.Trim()}");
// #698: Only the own unit's doctrine — one level deep, never the units above.
if (doctrine?.Invoke(unit.Id) is { IsEmpty: false } holds)
{
sb.AppendLine($"Doctrine of your unit — what holds here now (set by {holds.SetBy} on {holds.SetAt:yyyy-MM-dd}). " +
"It frames your tasks; if a task contradicts it, do not choose silently — escalate:");
sb.AppendLine(holds.Text.Trim());
}
}
var lead = org.EffectiveLeaderOf(agentId);
@@ -66,6 +76,9 @@ public static class OrgBriefing
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.");
if (canEscalate)
sb.AppendLine("You set your unit's doctrine (Escalation tool, doctrine_set): what holds in it now, short, changed seldom. " +
"It reaches the members of your unit only — sub-units have their own.");
// #687: What the unit lets its lead approve — told, so an approval task is no surprise.
if (led.Approvals is { Operations.Count: > 0 } approvals && canEscalate)
sb.AppendLine($"Your unit lets you approve, for the agents below you: {string.Join(", ", approvals.Operations)} " +
@@ -284,6 +284,18 @@ public sealed class SqliteStorage
-- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
-- genau der gespeicherte Argument-JSON (Plan-Freeze).
-- #698: a unit's doctrine, every version kept — who, when, which run, why.
CREATE TABLE IF NOT EXISTS Doctrines (
UnitId TEXT NOT NULL,
Version INTEGER NOT NULL,
Text TEXT NOT NULL,
SetBy TEXT NOT NULL,
SetAt TEXT NOT NULL,
RunId TEXT NULL,
Reason TEXT NOT NULL DEFAULT '',
PRIMARY KEY (UnitId, Version)
);
-- #699: cost alerts, once per period (day, or month for a threshold), scope and kind.
CREATE TABLE IF NOT EXISTS CostAlerts (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -52,6 +52,9 @@ public sealed record AgentToolContext(
/// <summary>#687: Approvals a lead decides for the agents below it. <c>null</c> outside the engine.</summary>
public Staging.ILeadApprovals? Approvals { get; init; }
/// <summary>#698: The units' doctrines. <c>null</c> outside the engine.</summary>
public Organisation.IDoctrineStore? Doctrines { get; init; }
/// <summary>#654: Where code runs — never in the main process. Unavailable under Windows.</summary>
public Sandbox.ISandboxRunner? Sandbox { get; init; }
+7
View File
@@ -90,6 +90,13 @@ public interface IAgentTool
/// </summary>
Staging.StagingDecision? Vorgabe(string operation, IReadOnlyDictionary<string, object?> config) => null;
/// <summary>
/// #698: The classification with the whole context of the call — for a rule that depends
/// on who calls, not only on how the tool is configured (a lead that reads foreign content
/// may not change its unit's doctrine unasked). Default: <see cref="Vorgabe(string, IReadOnlyDictionary{string, object?})"/>.
/// </summary>
Staging.StagingDecision? Vorgabe(string operation, AgentToolContext context) => Vorgabe(operation, context.ToolConfig);
Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,