diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md index 2d5c737..15a3af1 100644 --- a/docs/Organisation-Konzept.md +++ b/docs/Organisation-Konzept.md @@ -1071,6 +1071,18 @@ Ansonsten darf die Leitung sie ändern, ohne zu fragen. Das ist der Unterschied Identity und Soul, und er ist gewollt: Eine Leitung, die für jede Anweisung an ihre eigene Abteilung einen Menschen braucht, ist keine Leitung. +> **Umgesetzt (#698), mit zwei Abweichungen vom Wortlaut.** Die Doctrine ist **keine +> Datei**, sondern steht je Einheit versioniert in der Instanz-DB (`Doctrines`): eine Datei +> im Arbeitsbereich ließe sich mit FileRW an allen Regeln vorbei ändern, und die Versionen +> mit Autor, Lauf und Grund wären verloren. Und die Belastung wird an der **Vertrauensstufe +> der Leitung** gemessen (#715), nicht am einzelnen Lauf — eine Leitung mit Werkzeugen für +> Fremdinhalte legt `doctrine_set` immer zur Freigabe vor (neue Werkzeugregel +> `IAgentTool.Vorgabe(operation, context)`). Gesetzt wird mit dem Escalation-Werkzeug +> (`doctrine_set` mit Grund, `doctrine_read`), höchstens 2000 Zeichen, leer entfernt sie. +> Sie steht im Organisationsabschnitt jedes Mitglieds der Einheit, nicht der Untereinheiten, +> mit dem Satz, dass ein Widerspruch zur Aufgabe eskaliert wird. Der Mensch sieht und setzt +> sie in der Organisationsansicht; drei und mehr Änderungen in sieben Tagen sind markiert. + ### A/B: welches Modell macht es besser oder billiger? Die Eignungsprobe (#681) beantwortet „kann dieses Modell dieses Werkzeug bedienen?" — diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 4e80e25..2e1f84e 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -132,6 +132,9 @@ public sealed class AppHost : IAsyncDisposable /// #699: Costs per unit and the alerts on them. public Core.Accounting.CostWatch? CostWatch { get; private set; } + /// #698: The units' doctrines. + public Core.Organisation.IDoctrineStore? Doctrines { get; private set; } + /// Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund. public ModelPricingCatalog? Pricing { get; private init; } @@ -707,6 +710,11 @@ public sealed class AppHost : IAsyncDisposable historyKeeper.Start(); host._shutdown.Add(historyKeeper.DisposeAsync); + // #698: what holds in a unit, set by its lead. + var doctrines = new Core.Organisation.SqliteDoctrineStore(storage); + engine.Doctrines = doctrines; + host.Doctrines = doctrines; + // #699: costs per unit, and what changed in them. var costWatch = new Core.Accounting.CostWatch( storage, usage, () => Core.Organisation.OrgChart.From(instance), () => instance.CostWatch, board, diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 48f9a9e..fb8664d 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -54,6 +54,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut /// #689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context. public Cases.ICaseDesk? CaseDesk { get; set; } + /// #698: The units' doctrines; set by the host. + public Organisation.IDoctrineStore? Doctrines { get; set; } + /// #687: Approvals by leads within their unit's limits; set by the host. 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)); /// /// 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 /// #689: Whether agents can raise a case — the desk is set up and its tool registered. private bool CanEscalate => CaseDesk is not null && _toolRegistry.Get(Cases.CaseDesk.ToolName) is not null; + private Organisation.Doctrine? DoctrineOf(string unitId) => Doctrines?.Current(unitId); + /// #690: Whether the agent is the deciding instance at the top. 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, diff --git a/src/ClawdDotNet.Core/Organisation/Doctrine.cs b/src/ClawdDotNet.Core/Organisation/Doctrine.cs new file mode 100644 index 0000000..3cd7150 --- /dev/null +++ b/src/ClawdDotNet.Core/Organisation/Doctrine.cs @@ -0,0 +1,93 @@ +using System.Collections.Concurrent; +using System.Globalization; +using ClawdDotNet.Core.Storage; + +namespace ClawdDotNet.Core.Organisation; + +/// One version of a unit's doctrine — who set it, when, in which run, and why. +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 +{ + /// The doctrine that holds now; null when the unit has none. Read on every run — kept in memory. + Doctrine? Current(string unitId); + + Task SetAsync(string unitId, string text, string setBy, string? runId, string reason, CancellationToken ct); + + /// Every version, newest first. + Task> HistoryAsync(string unitId, CancellationToken ct); +} + +/// +/// #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). +/// +/// One per unit, one level deep. 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. +/// +/// Short and capped (): it goes into every run of every +/// member. Seldom and visible: every version is kept with its author, run and reason — +/// frequent changes are a finding, observed rather than prevented. +/// +public sealed class SqliteDoctrineStore(SqliteStorage storage) : IDoctrineStore +{ + /// Five agents, forty runs a day, a thousand tokens each — two hundred thousand tokens for one text. So: short. + public const int MaxLength = 2_000; + + private readonly ConcurrentDictionary _current = new(StringComparer.OrdinalIgnoreCase); + + public Doctrine? Current(string unitId) + => _current.GetOrAdd(unitId, id => LatestAsync(id, CancellationToken.None).GetAwaiter().GetResult()); + + public async Task 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> 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(); + 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 LatestAsync(string unitId, CancellationToken ct) + => (await HistoryAsync(unitId, ct)).FirstOrDefault() is { IsEmpty: false } latest ? latest : null; +} diff --git a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs index fe4f5e9..c7609bf 100644 --- a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs +++ b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs @@ -27,7 +27,9 @@ public static class OrgBriefing /// #690: The agent is the deciding instance at the top — it gets that /// section instead, with or without units. - public static string? For(OrgChart org, string agentId, DateTime nowUtc, AgentTrust? trust = null, bool canEscalate = false, bool atTop = false) + /// #698: The doctrine of a unit, if it has one. + public static string? For(OrgChart org, string agentId, DateTime nowUtc, AgentTrust? trust = null, bool canEscalate = false, bool atTop = false, + Func? 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)} " + diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index 397e42a..6456ed9 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -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, diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs index 760f5aa..7458875 100644 --- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs +++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs @@ -52,6 +52,9 @@ public sealed record AgentToolContext( /// #687: Approvals a lead decides for the agents below it. null outside the engine. public Staging.ILeadApprovals? Approvals { get; init; } + /// #698: The units' doctrines. null outside the engine. + public Organisation.IDoctrineStore? Doctrines { get; init; } + /// #654: Where code runs — never in the main process. Unavailable under Windows. public Sandbox.ISandboxRunner? Sandbox { get; init; } diff --git a/src/ClawdDotNet.Core/Tools/IAgentTool.cs b/src/ClawdDotNet.Core/Tools/IAgentTool.cs index 91dfb7b..ad7a5c7 100644 --- a/src/ClawdDotNet.Core/Tools/IAgentTool.cs +++ b/src/ClawdDotNet.Core/Tools/IAgentTool.cs @@ -90,6 +90,13 @@ public interface IAgentTool /// Staging.StagingDecision? Vorgabe(string operation, IReadOnlyDictionary config) => null; + /// + /// #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: . + /// + Staging.StagingDecision? Vorgabe(string operation, AgentToolContext context) => Vorgabe(operation, context.ToolConfig); + Task ExecuteAsync( JsonElement input, AgentToolContext context, diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index f5430e5..1186959 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -100,6 +100,11 @@ "org.approvalsOperations": "Operationen", "org.approvalsPerDay": "Höchstens je Tag", "org.approvalsHours": "Stunden je Freigabe", + "org.doctrine": "Doktrin", + "org.doctrineHint": "Was in dieser Einheit gerade gilt — Rahmen für jede Aufgabe ihrer Mitglieder, von ihrer Leitung ohne Rückfrage gesetzt. Sie erreicht nur die Mitglieder dieser Einheit, nicht die Untereinheiten; die haben ihre eigene. Kurz halten: Sie geht in jeden Lauf. Leer entfernt sie.", + "org.doctrinePlaceholder": "Jeder Lieferantenkontakt wird in der Datenbank erfasst, bevor er weitergegeben wird.", + "org.doctrineFrequent": "In den letzten 7 Tagen {count}-mal geändert — eine Doktrin, die oft wechselt, ist Mikromanagement mit Tokenkosten.", + "org.doctrineRemoved": "(entfernt)", "org.ceilingInherited": "Von oben begrenzt auf: {tools}", "org.settings": "Sammelwerte", "org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 3cda882..12f83bb 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -100,6 +100,11 @@ "org.approvalsOperations": "Operations", "org.approvalsPerDay": "Per day at most", "org.approvalsHours": "Hours per approval", + "org.doctrine": "Doctrine", + "org.doctrineHint": "What holds in this unit now — a frame for every task of its members, set by its lead without asking. It reaches this unit's members only, not the sub-units; they have their own. Short: it goes into every run. Empty removes it.", + "org.doctrinePlaceholder": "Every supplier contact is entered in the database before it is passed on.", + "org.doctrineFrequent": "Changed {count} times in the last 7 days — a doctrine that changes often is micro-management with token costs.", + "org.doctrineRemoved": "(removed)", "org.ceilingInherited": "Limited from above to: {tools}", "org.settings": "Unit-wide values", "org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js index 51d3c42..c89c94b 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js @@ -367,6 +367,7 @@ function unitPanel(unit) { cards.push(unitWorkspaceCard(unit)); cards.push(ceilingCard(unit)); cards.push(approvalsCard(unit)); + cards.push(doctrineCard(unit)); cards.push(settingsCard(unit)); // Members. @@ -462,6 +463,46 @@ function approvalsCard(unit) { ]); } +/** + * #698: What holds in the unit now — set by its lead, readable and settable here. It reaches + * the unit's members only, not the sub-units. Every version stays; frequent changes are marked, + * because a doctrine that changes weekly is micro-management with token costs. + */ +function doctrineCard(unit) { + const text = erstelleElement("textarea", { rows: 5, maxlength: "2000", placeholder: t("org.doctrinePlaceholder") }); + const history = erstelleElement("div", { klasse: "org-doktrin-verlauf" }); + const counter = erstelleElement("span", { klasse: "gedaempft text-klein" }); + const sync = () => { counter.textContent = `${text.value.length} / 2000`; }; + text.addEventListener("input", sync); + + (async () => { + let versions = []; + try { versions = await api(`/organisation/units/${encodeURIComponent(unit.id)}/doctrine`); } catch { versions = []; } + text.value = versions[0]?.text ?? ""; + sync(); + const weekAgo = Date.now() - 7 * 24 * 3600 * 1000; + const recent = versions.filter((v) => new Date(v.setAt).getTime() >= weekAgo).length; + if (recent >= 3) history.append(erstelleElement("p", { klasse: "hinweis-box warnung", text: t("org.doctrineFrequent", { count: recent }) })); + for (const v of versions.slice(0, 10)) { + history.append(erstelleElement("details", { klasse: "org-doktrin-version" }, + erstelleElement("summary", { text: `v${v.version} · ${v.setBy === "human" ? t("cases.humanAuthor") : v.setBy} · ${formatiereDatumZeit(v.setAt)}${v.reason ? " · " + v.reason : ""}` }), + erstelleElement("pre", { klasse: "code-block", text: v.text || t("org.doctrineRemoved") }))); + } + })(); + + return erstelleKarte(t("org.doctrine"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.doctrineHint") }), + text, + erstelleElement("div", { klasse: "gruppe-horizontal" }, + counter, + erstelleElement("button", { + type: "button", klasse: "klein", text: t("common.save"), + onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/doctrine`, "PUT", { text: text.value, reason: reason() }) + })), + history + ]); +} + function settingsCard(unit) { const setting = erstelleElement("select", {}, ...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) }))); diff --git a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs index cd703da..3533680 100644 --- a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs +++ b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs @@ -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(); + /// + /// #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. + /// + public StagingDecision? Vorgabe(string operation, AgentToolContext context) + => operation == "doctrine_set" && context.Trust.ReadsForeignContent ? StagingDecision.Approve : null; + /// #717: A request that cannot be granted is refused before it reaches a human. 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 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}"); diff --git a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs index c3706b8..a074ad7 100644 --- a/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs +++ b/src/ClawdDotNet.Web/Contracts/OrganisationDtos.cs @@ -22,6 +22,11 @@ public sealed record UnitDto( // #687: what the lead may approve instead of the human; null: nothing. UnitApprovalsDto? Approvals = null); +/// #698: One version of a unit's doctrine. +public sealed record DoctrineDto(int Version, string Text, string SetBy, DateTime SetAt, string? RunId, string Reason); + +public sealed record DoctrineInputDto(string? Text, string? Reason); + public sealed record UnitApprovalsDto(IReadOnlyList Operations, int PerDay, double Hours); public sealed record ApprovalsInputDto(IReadOnlyList? Operations, int? PerDay, double? Hours, string Reason = ""); diff --git a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs index fcc0094..2a311e0 100644 --- a/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/OrganisationEndpoints.cs @@ -81,6 +81,28 @@ internal static class OrganisationEndpoints ? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target))) : host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse()); + // #698: A unit's doctrine — every version, and the human setting it. + org.MapGet("/units/{unitId}/doctrine", async (AppHost host, string unitId, CancellationToken ct) => + { + if (host.Doctrines is not { } store) + return Results.Ok(Array.Empty()); + return Results.Ok((await store.HistoryAsync(unitId, ct)) + .Select(d => new DoctrineDto(d.Version, d.Text, d.SetBy, d.SetAt, d.RunId, d.Reason)).ToList()); + }); + + org.MapPut("/units/{unitId}/doctrine", async (AppHost host, string unitId, DoctrineInputDto input, CancellationToken ct) => + { + if (host.Doctrines is not { } store) + return Results.BadRequest(new OutcomeDto(false, "Doctrines are not available in this instance.")); + if (host.Organisation.Current().Unit(unitId) is not { } unit) + return Results.BadRequest(new OutcomeDto(false, $"There is no unit '{unitId}'.")); + var text = (input.Text ?? "").Trim(); + if (text.Length > Core.Organisation.SqliteDoctrineStore.MaxLength) + return Results.BadRequest(new OutcomeDto(false, $"A doctrine has at most {Core.Organisation.SqliteDoctrineStore.MaxLength} characters.")); + var set = await store.SetAsync(unit.Id, text, "human", null, input.Reason ?? "", ct); + return Results.Ok(new OutcomeDto(true, set.IsEmpty ? $"Doctrine of '{unit.Name}' removed." : $"Doctrine of '{unit.Name}' set (version {set.Version}).")); + }); + // #687: What the lead may approve instead of the human. org.MapPut("/units/{unitId}/approvals", (AppHost host, string unitId, ApprovalsInputDto input) => host.Organisation.SetApprovals(unitId, input.Operations, input.PerDay, input.Hours, input.Reason).ToResponse()); diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/DoctrineTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/DoctrineTests.cs new file mode 100644 index 0000000..3326bdf --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/DoctrineTests.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.Staging; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tools; +using ClawdDotNet.Tools.Taskboard; +using ClawdDotNet.Tools.Tests.FileRW; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.Taskboard; + +/// +/// #698: The lead steers its unit without touching every task text — one level deep, short, +/// every version kept, and not unasked from a lead that reads foreign content. +/// +public sealed class DoctrineTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + private readonly SqliteDoctrineStore _store; + private readonly EscalationTool _tool = new(); + + /// purchasing (buy-lead; buyer) › team (team-lead; clerk). + private readonly OrgChart _org = new( + [ + new UnitConfig { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead" }, + new UnitConfig { Id = "team", Name = "Team", ParentId = "purchasing", LeaderAgentId = "team-lead" } + ], + [ + new AgentConfig { AgentId = "buy-lead", UnitId = "purchasing" }, + new AgentConfig { AgentId = "buyer", UnitId = "purchasing" }, + new AgentConfig { AgentId = "team-lead", UnitId = "team" }, + new AgentConfig { AgentId = "clerk", UnitId = "team" } + ]); + + public DoctrineTests() => _store = new SqliteDoctrineStore(new SqliteStorage(Path.Combine(_directory, "state.db"))); + + public void Dispose() + { + SqliteStorage.ReleasePooledConnections(_directory); + try { Directory.Delete(_directory, recursive: true); } + catch { /* cleanup is secondary */ } + } + + private AgentToolContext Context(string agent, AgentTrust? trust = null) + => new(agent, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, CancellationToken.None) + { + Organisation = _org, + Doctrines = _store, + RunId = "run-" + agent, + Trust = trust ?? AgentTrust.Standard + }; + + private Task Run(string agent, object input) + => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), Context(agent), CancellationToken.None); + + [Fact] + public async Task The_lead_sets_the_doctrine_and_its_members_read_it() + { + var set = await Run("buy-lead", new { action = "doctrine_set", text = "Every supplier contact goes into the database first.", reason = "Contacts got lost." }); + + set.Success.ShouldBeTrue(set.ErrorMessage); + (await Run("buyer", new { action = "doctrine_read" })).Content.ShouldContain("supplier contact"); + _store.Current("purchasing")!.SetBy.ShouldBe("buy-lead"); + } + + [Fact] + public async Task Only_a_lead_sets_it_with_a_reason_and_within_the_length() + { + (await Run("buyer", new { action = "doctrine_set", text = "x", reason = "y" })).ErrorMessage!.ShouldContain("Only the lead"); + (await Run("buy-lead", new { action = "doctrine_set", text = "x" })).ErrorMessage!.ShouldContain("'reason'"); + (await Run("buy-lead", new { action = "doctrine_set", text = new string('x', 2001), reason = "y" })).ErrorMessage!.ShouldContain("2000"); + } + + [Fact] + public async Task It_reaches_one_level_deep_and_no_further() + { + await _store.SetAsync("purchasing", "Purchasing rule.", "buy-lead", null, "r", default); + await _store.SetAsync("team", "Team rule.", "team-lead", null, "r", default); + + var buyer = OrgBriefing.For(_org, "buyer", DateTime.UtcNow, doctrine: _store.Current)!; + var clerk = OrgBriefing.For(_org, "clerk", DateTime.UtcNow, doctrine: _store.Current)!; + + buyer.ShouldContain("Purchasing rule."); + clerk.ShouldContain("Team rule."); + clerk.ShouldNotContain("Purchasing rule.", customMessage: "the team has its own lead and its own doctrine"); + } + + [Fact] + public async Task Every_version_is_kept_and_an_empty_text_removes_it() + { + await Run("buy-lead", new { action = "doctrine_set", text = "First.", reason = "a" }); + await Run("buy-lead", new { action = "doctrine_set", text = "", reason = "no longer needed" }); + + _store.Current("purchasing").ShouldBeNull(); + var history = await _store.HistoryAsync("purchasing", default); + history.Select(d => d.Version).ShouldBe([2, 1]); + history[1].Text.ShouldBe("First."); + history[0].RunId.ShouldBe("run-buy-lead"); + } + + [Fact] + public void A_lead_that_reads_foreign_content_needs_an_approval_to_set_it() + { + _tool.Vorgabe("doctrine_set", Context("buy-lead", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"]))) + .ShouldBe(StagingDecision.Approve); + _tool.Vorgabe("doctrine_set", Context("buy-lead")).ShouldBeNull("a lead of the standard tier sets it without asking"); + _tool.Vorgabe("doctrine_read", Context("buy-lead", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"]))).ShouldBeNull(); + } +}