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
@@ -3,6 +3,8 @@ using System.Text.Json;
using System.Globalization;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Cases;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
@@ -31,6 +33,8 @@ public sealed class EscalationTool : IAgentTool
- state_position: answer a request for your position or a statement.
After the deadline a human decides.
- request_budget: a special budget for one large task and its parts (task, amount_usd, reasoning). A human always decides; it does not raise your daily limit.
- doctrine_read: the doctrine of your unit — what holds there now.
As a lead: doctrine_set (text, reason) sets your unit's doctrine — short, changed seldom; empty text removes it. Every version is kept.
As a lead whose unit may approve: list_approvals shows the calls waiting for you; approve_call (call) or reject_call (call, reason). Never your own calls; if unsure, leave it to the human.
""";
@@ -40,7 +44,7 @@ public sealed class EscalationTool : IAgentTool
"properties": {
"action": {
"type": "string",
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call"]
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set"]
},
"amount_usd": { "type": "number", "description": "request_budget: the amount for the task and all its parts, in USD." },
"subject": { "type": "string", "description": "What it is about, in a few words — like a title. escalate, open_conflict, find_decisions." },
@@ -57,12 +61,21 @@ public sealed class EscalationTool : IAgentTool
"with": { "type": "string", "description": "open_conflict: the agent you disagree with." },
"position": { "type": "string", "description": "open_conflict, state_position: your side, with reasons." },
"call": { "type": "integer", "description": "approve_call, reject_call: the approval number (#…)." },
"reason": { "type": "string", "description": "reject_call: why — the agent must know what to change." }
"reason": { "type": "string", "description": "reject_call: why — the agent must know what to change. doctrine_set: why the doctrine changes." },
"text": { "type": "string", "description": "doctrine_set: what holds in your unit now. At most 2000 characters; empty removes it." }
},
"required": ["action"]
}
""").RootElement.Clone();
/// <summary>
/// #698: A lead that reads content from outside may still set its unit's doctrine — but not
/// unasked: the doctrine stands in every prompt of every member, the most convenient way to
/// slip something to a whole unit from outside. A human approves it first.
/// </summary>
public StagingDecision? Vorgabe(string operation, AgentToolContext context)
=> operation == "doctrine_set" && context.Trust.ReadsForeignContent ? StagingDecision.Approve : null;
/// <summary>#717: A request that cannot be granted is refused before it reaches a human.</summary>
public string? Preflight(JsonElement input, AgentToolContext context)
=> Get(input, "action") == "request_budget" ? CheckBudgetRequest(input, context, out _, out _) : null;
@@ -73,6 +86,10 @@ public sealed class EscalationTool : IAgentTool
if (Get(input, "action") == "request_budget")
return await SetUpBudgetAsync(input, context, ct);
// #698: The unit's doctrine.
if (Get(input, "action") is "doctrine_read" or "doctrine_set")
return await DoctrineAsync(input, context, ct);
// #687: A lead deciding the approvals its unit grants it.
if (Get(input, "action") is "list_approvals" or "approve_call" or "reject_call")
return await ApprovalAsync(input, context, ct);
@@ -227,6 +244,35 @@ public sealed class EscalationTool : IAgentTool
return decided.Ok ? ToolResult.Ok(decided.Message) : ToolResult.Fail(decided.Message);
}
private static async Task<ToolResult> DoctrineAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (context.Doctrines is not { } doctrines || context.Organisation is not { HasUnits: true } org)
return ToolResult.Fail("Doctrines exist only in an instance with units.");
if (Get(input, "action") == "doctrine_read")
{
var unit = org.LedBy(context.AgentId) ?? org.UnitOf(context.AgentId);
if (unit is null)
return ToolResult.Fail("You belong to no unit, so no doctrine applies to you.");
return ToolResult.Ok(doctrines.Current(unit.Id) is { } holds
? $"Doctrine of \"{unit.Name}\" (version {holds.Version}, set by {holds.SetBy} on {holds.SetAt:yyyy-MM-dd}):\n{holds.Text}"
: $"\"{unit.Name}\" has no doctrine.");
}
if (org.LedBy(context.AgentId) is not { } led)
return ToolResult.Fail("Only the lead of a unit sets its doctrine. Raise what should hold with your lead.");
var text = Get(input, "text") ?? "";
if (text.Trim().Length > SqliteDoctrineStore.MaxLength)
return ToolResult.Fail($"A doctrine has at most {SqliteDoctrineStore.MaxLength} characters — it goes into every run of every member. Shorten it.");
if (string.IsNullOrWhiteSpace(Get(input, "reason")))
return ToolResult.Fail("'reason' is required — why the doctrine changes. Every version is kept with it.");
var set = await doctrines.SetAsync(led.Id, text, context.AgentId, context.RunId, Get(input, "reason")!.Trim(), ct);
return ToolResult.Ok(set.IsEmpty
? $"The doctrine of \"{led.Name}\" is removed (version {set.Version})."
: $"Doctrine of \"{led.Name}\" set (version {set.Version}). It reaches the members of your unit from their next run on — not the sub-units.");
}
private static string Describe(Decision d)
=> CaseDesk.DecisionLine(d) + (string.IsNullOrWhiteSpace(d.Reasoning) ? "" : $"\n Reasoning: {d.Reasoning}");