Compare commits
2
Commits
c608af1abb
...
888369698d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
888369698d | ||
|
|
679333b382 |
@@ -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?" —
|
||||
|
||||
@@ -132,6 +132,9 @@ public sealed class AppHost : IAsyncDisposable
|
||||
/// <summary>#699: Costs per unit and the alerts on them.</summary>
|
||||
public Core.Accounting.CostWatch? CostWatch { get; private set; }
|
||||
|
||||
/// <summary>#698: The units' doctrines.</summary>
|
||||
public Core.Organisation.IDoctrineStore? Doctrines { get; private set; }
|
||||
|
||||
/// <summary>Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund.</summary>
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}`) })));
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ public sealed record UnitDto(
|
||||
// #687: what the lead may approve instead of the human; null: nothing.
|
||||
UnitApprovalsDto? Approvals = null);
|
||||
|
||||
/// <summary>#698: One version of a unit's doctrine.</summary>
|
||||
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<string> Operations, int PerDay, double Hours);
|
||||
|
||||
public sealed record ApprovalsInputDto(IReadOnlyList<string>? Operations, int? PerDay, double? Hours, string Reason = "");
|
||||
|
||||
@@ -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<DoctrineDto>());
|
||||
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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>purchasing (buy-lead; buyer) › team (team-lead; clerk).</summary>
|
||||
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<string, object?>(), new NullStateStore(), NullLogger.Instance, CancellationToken.None)
|
||||
{
|
||||
Organisation = _org,
|
||||
Doctrines = _store,
|
||||
RunId = "run-" + agent,
|
||||
Trust = trust ?? AgentTrust.Standard
|
||||
};
|
||||
|
||||
private Task<ToolResult> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user