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:
co-authored by
Claude Opus 5.5
parent
c608af1abb
commit
679333b382
@@ -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; }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user