2 Commits
Author SHA1 Message Date
RichardandClaude Opus 5.5 305b177650 Merge feat/687-lead-approvals: Freigabe durch die Leitung (#687)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:20:34 +02:00
RichardandClaude Opus 5.5 f51186269c Freigabe durch die Leitung innerhalb von Grenzen (#687)
- UnitConfig.Approvals: Operationen, Hoechstzahl je Tag, Stunden je Freigabe; nur der
  Mensch setzt sie (OrgAdministration.SetApprovals mit Audit, Teilmengenregel nach oben,
  Senken oben senkt unten mit; Agentenaenderungen/Sonderbudget nie freigebbar)
- LeadApprovals: Vorlage geht an die naechste Leitung, deren Einheit die Operation
  erlaubt; nie eigene Aufrufe, nie eine Leitung mit Fremdinhalt, Tagesdeckel, Frist.
  Entscheidung ueber Escalation approve_call/reject_call, im Audit als lead:<id>
- Der Mensch sieht alles und kann zuerst entscheiden; danach wird die Leitungsaufgabe
  geschlossen. Nach der Frist nur noch der Mensch.
- StagedCalls: Approver, ApproverUntil, ApproverTaskId (Nachruestung)
- WebUI: Hinweis "bei Leitung" in der Freigabestelle, Zaehler ohne diese, Quote je
  Leitung; Karte in der Organisationsansicht; i18n en/de
- Tests: LeadApprovalsTests (13), OrgAdministrationTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:12:18 +02:00
27 changed files with 948 additions and 20 deletions
+13
View File
@@ -1457,6 +1457,19 @@ der Versuch macht ihn zum Flaschenhals.
Agenten kann ein Mensch die Läufe lesen. Bei fünfzig muss etwas anderes ihm sagen, wo
er hinsehen soll.
> **Umgesetzt (#687).** Die Grenze steht an der Einheit (`units[].approvals`: Operationen
> `Tool.operation` oder `Tool`, Höchstzahl je Tag, Stunden je Freigabe) und setzt nur der
> Mensch — WebUI *Organisation › Einheit › Freigaben durch die Leitung*, mit Audit. Ein
> vorgelegter Aufruf geht an die nächste Leitung über dem Agenten, deren Einheit die
> Operation erlaubt; sie entscheidet mit dem Escalation-Werkzeug (`approve_call`,
> `reject_call` mit Grund). Der Mensch sieht ihn die ganze Zeit und kann zuerst
> entscheiden; nach Ablauf entscheidet nur er. Nie: eigene Aufrufe, Änderungen an Agenten
> und Sonderbudgets (auch nicht über eine Freigabe des ganzen Werkzeugs), eine Leitung, die
> Fremdinhalte liest. Eine Untereinheit erlaubt nie mehr als die Einheiten darüber; wird
> oben gesenkt, sinkt es unten mit. Im Audit steht `lead:<id>`, die Freigabestelle zeigt
> die Quote je Leitung. **Offen:** Beträge je Vorgang und Tag — kein Werkzeug meldet
> heute einen Betrag; das kommt mit dem ersten Werkzeug, das Geld bewegt.
---
## 10a — Das Lagebild
+10 -1
View File
@@ -642,7 +642,7 @@ public sealed class AppHost : IAsyncDisposable
var (scanner, staging, board) = BuildTaskboard(
instance, taskRepository, engine, tools, stateStore, audit,
stagingRepository, dauerfreigaben, cases, loggerFactory, logger);
stagingRepository, dauerfreigaben, stagingGate, cases, loggerFactory, logger);
var host = new AppHost
{
@@ -800,6 +800,7 @@ public sealed class AppHost : IAsyncDisposable
SqliteAuditRepository audit,
SqliteStagingRepository stagingRepository,
SqliteDauerfreigabeRepository dauerfreigaben,
StagingGate stagingGate,
Core.Cases.ICaseRepository cases,
ILoggerFactory loggerFactory,
ILogger logger)
@@ -816,6 +817,14 @@ public sealed class AppHost : IAsyncDisposable
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit,
dauerfreigaben: dauerfreigaben);
// #687: A lead within its unit's limits decides instead of the human.
var leadApprovals = new LeadApprovals(
stagingRepository, board, () => engine.OrganisationProvider?.Invoke(),
agent => Core.Organisation.ReportingLine.TrustOf(agent, tools.Get),
loggerFactory.CreateLogger("ClawdDotNet.Core.Staging.Leads")) { Service = staging };
stagingGate.Route = leadApprovals.RouteAsync;
engine.LeadApprovals = leadApprovals;
var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory,
tasks: taskRepository);
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Cases;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
@@ -201,6 +202,84 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
return Preview([agentId], NullIfEmpty(targetUnitId), movingUnit: null, problems);
}
// ─── approvals by the lead (#687) ───
/// <summary>
/// #687: What the lead of a unit may approve instead of the human. Only a human sets it.
/// Changes to agents are never grantable; a sub-unit gets no more than every unit above it
/// that grants anything; lowering a unit's grant lowers its sub-units' with it. <c>null</c>
/// or an empty list takes the grant away.
/// </summary>
public AdminResult SetApprovals(string unitId, IReadOnlyCollection<string>? operations, int? perDay = null, double? hours = null, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
var ops = (operations ?? []).Select(o => o.Trim()).Where(o => o.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
foreach (var op in ops)
{
var dot = op.IndexOf('.');
var tool = dot < 0 ? op : op[..dot];
var operation = dot < 0 ? null : op[(dot + 1)..];
if (tool.Length == 0 || (dot >= 0 && string.IsNullOrWhiteSpace(operation)))
return AdminResult.Failure($"'{op}' is no operation — write Tool.operation, or Tool for all of its operations.");
// A whole tool that holds such an operation is refused too — "AgentEditor" would read as a grant for it.
if (FreigabeRegeln.IstImmerFreigabe(tool, operation)
|| (operation is null && FreigabeRegeln.ImmerFreigabe.Any(e => e.StartsWith(tool + ".", StringComparison.OrdinalIgnoreCase))))
return AdminResult.Failure($"'{op}' changes agents or budgets — that always stays with a human.");
}
if (perDay is < 0)
return AdminResult.Failure("The daily cap cannot be negative.");
if (hours is <= 0)
return AdminResult.Failure("The lead's time comes in hours, above zero.");
// A sub-unit can grant no more than the units above it.
foreach (var above in Chart.Ancestors(unit.Id))
{
if (above.Approvals is not { Operations.Count: > 0 } granted)
continue;
var beyond = ops.Where(op => !Within(granted, op)).ToList();
if (beyond.Count > 0)
return AdminResult.Failure($"'{above.Name}' above grants less: {string.Join(", ", beyond)} is beyond it. Raise it there first.");
}
unit.Approvals = ops.Count == 0 ? null : new UnitApprovals
{
Operations = ops,
PerDay = perDay ?? unit.Approvals?.PerDay ?? 20,
Hours = hours ?? unit.Approvals?.Hours ?? 4
};
// What the units below grant beyond this goes with it.
var trimmed = new List<string>();
foreach (var below in Subtree(Chart, unit.Id).Where(u => !Same(u.Id, unit.Id)))
{
if (FindUnit(below.Id) is not { Approvals: { } theirs } child)
continue;
var keep = unit.Approvals is null ? [] : theirs.Operations.Where(op => Within(unit.Approvals, op)).ToList();
if (keep.Count == theirs.Operations.Count)
continue;
trimmed.Add(child.Name);
child.Approvals = keep.Count == 0 ? null : new UnitApprovals { Operations = keep, PerDay = theirs.PerDay, Hours = theirs.Hours };
}
Save(null, "unit_approvals", new { unitId, operations = ops, perDay = unit.Approvals?.PerDay, hours = unit.Approvals?.Hours, trimmed }, reason);
var message = unit.Approvals is null
? $"The lead of '{unit.Name}' approves nothing now; a human decides."
: $"The lead of '{unit.Name}' may approve {string.Join(", ", ops)} (up to {unit.Approvals.PerDay} a day, {unit.Approvals.Window.TotalHours:0.##} h each).";
return AdminResult.Success(trimmed.Count == 0 ? message : message + $" Lowered with it: {string.Join(", ", trimmed)}.");
}
/// <summary>Whether a grant covers an operation string — "Tool" covers every "Tool.x".</summary>
private static bool Within(UnitApprovals granted, string op)
{
var dot = op.IndexOf('.');
return dot < 0
? granted.Operations.Any(g => Same(g, op))
: granted.Covers(op[..dot], op[(dot + 1)..]);
}
// ─── the top (#690) ───
private bool IsTopAgent(string agentId)
@@ -806,7 +885,7 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
private static UnitConfig Clone(UnitConfig u) => new()
{
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults, Approvals = u.Approvals
};
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
+39
View File
@@ -49,6 +49,45 @@ public sealed class UnitConfig
/// <summary>Starting values for agents that join this unit.</summary>
[JsonPropertyName("defaults")]
public UnitDefaults Defaults { get; set; } = new();
/// <summary>
/// #687: What the lead of this unit may approve for the agents below it; <c>null</c> means
/// nothing — every approval goes to a human. Set only by a human.
/// </summary>
[JsonPropertyName("approvals")]
public UnitApprovals? Approvals { get; set; }
}
/// <summary>
/// #687: The limits within which a unit's lead approves instead of the human. They belong to
/// the unit, not to the lead as a person — otherwise the power moves with an agent that changes
/// its post, and a vacant lead would have none.
/// </summary>
public sealed class UnitApprovals
{
/// <summary>
/// The operations the lead may approve: <c>Tool.operation</c>, or <c>Tool</c> for all of its
/// operations. A sub-unit can grant no more than every unit above it that grants anything.
/// </summary>
[JsonPropertyName("operations")]
public List<string> Operations { get; set; } = [];
/// <summary>Approvals the lead may give in 24 hours; beyond that the human decides.</summary>
[JsonPropertyName("perDay")]
public int PerDay { get; set; } = 20;
/// <summary>How long the lead has; after that the call is the human's alone.</summary>
[JsonPropertyName("hours")]
public double Hours { get; set; } = 4;
[JsonIgnore]
public TimeSpan Window => TimeSpan.FromHours(Math.Clamp(Hours, 0.25, 72));
/// <summary>Whether this grant covers the operation.</summary>
public bool Covers(string tool, string? operation)
=> Operations.Any(o => string.Equals(o, tool, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrWhiteSpace(operation)
&& string.Equals(o, $"{tool}.{operation}", StringComparison.OrdinalIgnoreCase)));
}
/// <summary>
@@ -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>#687: Approvals by leads within their unit's limits; set by the host.</summary>
public Staging.ILeadApprovals? LeadApprovals { get; set; }
/// <summary>
/// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the
/// daily limits; without this, every run counts against the day.
@@ -1864,6 +1867,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Organisation = CurrentOrganisation(),
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
Cases = CaseDesk,
Approvals = LeadApprovals,
SpecialBudgets = SpecialBudgets,
Sandbox = Sandbox,
Files = Files,
@@ -66,6 +66,12 @@ 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.");
// #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)} " +
$"(up to {approvals.PerDay} a day; you have {approvals.Window.TotalHours:0.##} h each, then a human decides). " +
"Check each call against the agent's task and run, not its own account. Never your own calls. " +
"An approval you give is recorded as yours, and how often you approve is watched.");
var subUnits = org.Children(led.Id);
if (subUnits.Count > 0)
{
@@ -0,0 +1,216 @@
using System.Text;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Tasks;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Staging;
/// <summary>#687: How one lead has approved — the rate is itself a finding.</summary>
public sealed record LeadApprovalStats(string LeadId, int Routed, int Approved, int Rejected, int Lapsed)
{
/// <summary>Share of its decisions that were approvals; <c>null</c> before the first.</summary>
public double? ApprovalRate => Approved + Rejected == 0 ? null : (double)Approved / (Approved + Rejected);
}
/// <summary>What a lead's decision came to.</summary>
public sealed record LeadDecision(bool Ok, string Message)
{
public static LeadDecision Fail(string message) => new(false, message);
}
/// <summary>The lead's side of an approval: what it decides with, and what it gets back.</summary>
public interface ILeadApprovals
{
Task<LeadDecision> DecideAsync(long callId, string leadId, AgentTrust trust, bool approve, string reason, CancellationToken ct);
/// <summary>The calls waiting for this lead now.</summary>
Task<IReadOnlyList<StagedCall>> PendingForAsync(string leadId, CancellationToken ct);
/// <summary>How each lead decided what it was given, over the last days.</summary>
Task<IReadOnlyList<LeadApprovalStats>> StatsAsync(int days, CancellationToken ct);
}
/// <summary>
/// #687: Approval by the lead within limits — the human must not be the bottleneck of fifty
/// agents (Organisation-Konzept, section 10).
///
/// <para><b>The limits belong to the unit</b> (<see cref="UnitApprovals"/>), and only a human
/// sets them. A staged call goes to the nearest lead above the agent whose unit grants the
/// operation — for a window of hours. The human sees it all the time and can decide it first;
/// after the window it is the human's alone. Nothing is approved by waiting.</para>
///
/// <para><b>Two hard rules.</b> A lead never approves its own calls — otherwise approval is a
/// formality with extra cost. What changes the structure — agents, their texts and rights —
/// always stays with the human (<see cref="FreigabeRegeln.IstImmerFreigabe"/>).</para>
///
/// <para><b>A lead that reads foreign content does not approve.</b> Its judgement is the
/// most rewarding target an injected text can have (#715).</para>
///
/// <para><b>A lead that approves is a model that approves.</b> Every decision stands in the
/// audit as <c>lead:&lt;id&gt;</c>, and the approval rate per lead is shown: a lead that
/// approves everything does nothing but cost.</para>
/// </summary>
public sealed class LeadApprovals(
IStagingRepository repo,
TaskboardService board,
Func<OrgChart?> organisation,
Func<AgentConfig, AgentTrust> trustOf,
ILogger logger,
TimeProvider? clock = null) : ILeadApprovals
{
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
/// <summary>Set once the service exists — it needs the gate, the gate needs this.</summary>
public StagingService? Service { get; set; }
public const string DecidedByPrefix = "lead:";
private DateTime Now => _clock.GetUtcNow().UtcDateTime;
/// <summary>
/// The lead that may decide this call, with the unit that grants it — or <c>null</c>: the
/// nearest lead above the agent whose unit covers the operation, never the agent itself, never
/// one that reads foreign content.
/// </summary>
public static (AgentConfig Lead, UnitConfig Unit)? ApproverFor(
OrgChart? org, string agentId, string tool, string? operation, Func<AgentConfig, AgentTrust> trustOf)
{
if (org is not { HasUnits: true } || FreigabeRegeln.IstImmerFreigabe(tool, operation))
return null;
if (org.UnitOf(agentId) is not { } unit)
return null;
foreach (var candidate in new[] { unit }.Concat(org.Ancestors(unit.Id)))
{
if (org.LeaderOf(candidate.Id) is not { } lead
|| string.Equals(lead.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
continue;
if (candidate.Approvals?.Covers(tool, operation) != true)
continue;
if (trustOf(lead).ReadsForeignContent)
continue;
return (lead, candidate);
}
return null;
}
/// <summary>
/// Gives a freshly staged call to its lead, if one may decide it and has room today.
/// Returns the line the proposing agent reads, or <c>null</c>: the human decides.
/// </summary>
public async Task<string?> RouteAsync(StagedCall call, CancellationToken ct)
{
var org = organisation();
if (ApproverFor(org, call.AgentId, call.Tool, call.Action, trustOf) is not var (lead, unit))
return null;
var today = (await repo.ListRoutedSinceAsync(Now.AddDays(-1), ct))
.Count(c => string.Equals(c.Approver, lead.AgentId, StringComparison.OrdinalIgnoreCase));
if (today >= Math.Max(0, unit.Approvals!.PerDay))
{
logger.LogInformation("Approval #{Id}: lead {Lead} has reached its {Cap} for today — a human decides", call.Id, lead.AgentId, unit.Approvals.PerDay);
return null;
}
var until = Now + unit.Approvals.Window;
var task = await board.CreateAsync(new TaskItem
{
Title = $"Approval #{call.Id}: {Label(call)} from {call.AgentId}",
Body = Request(call, until),
Assignee = "@" + lead.AgentId,
Priority = 4,
Acceptance = "The call is approved or rejected with a reason — or left to the human.",
OriginRunId = call.RunId
}, ct);
await repo.SetApproverAsync(call.Id, lead.AgentId, until, task.Id, ct);
logger.LogInformation("Approval #{Id} ({Tool}) of {Agent} goes to its lead {Lead} until {Until:u}",
call.Id, Label(call), call.AgentId, lead.AgentId, until);
return $"Your lead {lead.AgentId} decides it (until {until:yyyy-MM-dd HH:mm} UTC), otherwise a human.";
}
public async Task<LeadDecision> DecideAsync(long callId, string leadId, AgentTrust trust, bool approve, string reason, CancellationToken ct)
{
if (Service is not { } service)
return LeadDecision.Fail("Approvals are not available in this instance.");
if (await repo.GetAsync(callId, ct) is not { } call)
return LeadDecision.Fail($"There is no approval #{callId}.");
if (call.Status != StagingStatus.Pending)
return LeadDecision.Fail($"Approval #{callId} is already decided ({call.Status}).");
if (!string.Equals(call.Approver, leadId, StringComparison.OrdinalIgnoreCase))
return LeadDecision.Fail($"Approval #{callId} is not yours to decide.");
if (!call.WithLead(Now))
return LeadDecision.Fail($"Your time for approval #{callId} has run out — a human decides it now.");
if (string.Equals(call.AgentId, leadId, StringComparison.OrdinalIgnoreCase))
return LeadDecision.Fail("You never approve your own calls.");
if (trust.ReadsForeignContent)
return LeadDecision.Fail("You read content from outside, so you do not approve — a human decides it.");
// The structure may have changed since: the grant must still hold, and still be this lead's.
var now = ApproverFor(organisation(), call.AgentId, call.Tool, call.Action, trustOf);
if (now is not var (lead, _) || !string.Equals(lead.AgentId, leadId, StringComparison.OrdinalIgnoreCase))
return LeadDecision.Fail($"Your unit no longer grants you approval #{callId} — a human decides it.");
if (!approve && string.IsNullOrWhiteSpace(reason))
return LeadDecision.Fail("A rejection needs its reason — the agent must know what to change.");
var decidedBy = DecidedByPrefix + leadId;
var result = approve
? await service.ApproveAsync(callId, decidedBy, ct)
: await service.RejectAsync(callId, decidedBy, reason.Trim(), ct);
return result.Kind switch
{
StagingResultKind.Executed => new LeadDecision(true, $"Approval #{callId} approved and carried out: {Clip(result.Message, 300)}"),
StagingResultKind.Failed => new LeadDecision(true, $"Approval #{callId} approved, but it failed: {Clip(result.Message, 300)}"),
StagingResultKind.Rejected => new LeadDecision(true, $"Approval #{callId} rejected. The agent is told why."),
_ => LeadDecision.Fail(result.Message)
};
}
public async Task<IReadOnlyList<StagedCall>> PendingForAsync(string leadId, CancellationToken ct)
=> (await repo.ListPendingAsync(ct))
.Where(c => string.Equals(c.Approver, leadId, StringComparison.OrdinalIgnoreCase) && c.WithLead(Now))
.ToList();
/// <summary>How each lead decided what it was given, over the last <paramref name="days"/> days.</summary>
public async Task<IReadOnlyList<LeadApprovalStats>> StatsAsync(int days, CancellationToken ct)
{
var now = Now;
return (await repo.ListRoutedSinceAsync(now.AddDays(-Math.Max(1, days)), ct))
.GroupBy(c => c.Approver!, StringComparer.OrdinalIgnoreCase)
.Select(g =>
{
var byLead = g.Where(c => string.Equals(c.DecidedBy, DecidedByPrefix + g.Key, StringComparison.OrdinalIgnoreCase)).ToList();
return new LeadApprovalStats(
g.Key,
g.Count(),
byLead.Count(c => c.Status is StagingStatus.Executed or StagingStatus.Failed or StagingStatus.Approved),
byLead.Count(c => c.Status == StagingStatus.Rejected),
g.Count(c => !byLead.Contains(c) && !c.WithLead(now)));
})
.OrderBy(s => s.LeadId, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string Request(StagedCall call, DateTime until)
{
var sb = new StringBuilder();
sb.AppendLine($"{call.AgentId} proposes {Label(call)} and needs an approval (#{call.Id}). Your unit lets you decide it " +
$"until {until:yyyy-MM-dd HH:mm} UTC; after that a human does. A human may also decide it before you.");
sb.AppendLine();
sb.AppendLine("The call, exactly as it would run:");
sb.AppendLine(Engine.UntrustedContent.Rahmen(call.Tool, string.IsNullOrWhiteSpace(call.ArgumentsJson) ? call.Proposal : call.ArgumentsJson));
sb.AppendLine();
sb.AppendLine("Check it against what the agent was asked to do — look at its task and run, not at what it says about itself. " +
$"Then use the Escalation tool: approve_call (call {call.Id}) or reject_call (call {call.Id}, reason). " +
"If you are unsure, leave it: the human decides. Approving everything is not approving.");
return sb.ToString().TrimEnd();
}
private static string Label(StagedCall call)
=> string.IsNullOrWhiteSpace(call.Action) ? call.Tool : $"{call.Tool}.{call.Action}";
private static string Clip(string text, int max) => text.Length <= max ? text : text[..max] + "…";
}
@@ -168,11 +168,40 @@ public sealed class SqliteStagingRepository : IStagingRepository
return await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public Task SetApproverAsync(long id, string approver, DateTime until, string taskId, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE StagedCalls SET Approver = @approver, ApproverUntil = @until, ApproverTaskId = @task
WHERE Id = @id AND Status = 'Pending'
""";
cmd.Parameters.AddWithValue("@approver", approver);
cmd.Parameters.AddWithValue("@until", Format(until));
cmd.Parameters.AddWithValue("@task", taskId);
cmd.Parameters.AddWithValue("@id", id);
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public async Task<IReadOnlyList<StagedCall>> ListRoutedSinceAsync(DateTime since, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE Approver IS NOT NULL AND CreatedAt >= @since ORDER BY Id";
cmd.Parameters.AddWithValue("@since", Format(since));
var results = new List<StagedCall>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(Read(reader));
return results;
}
// ─── Hilfsfunktionen ───
private const string Columns =
"Id, RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, " +
"CreatedAt, DecidedAt, DecidedBy, ResultRef, RejectionReason";
"CreatedAt, DecidedAt, DecidedBy, ResultRef, RejectionReason, Approver, ApproverUntil, ApproverTaskId";
private static StagedCall Read(SqliteDataReader r) => new()
{
@@ -189,7 +218,10 @@ public sealed class SqliteStagingRepository : IStagingRepository
DecidedAt = r.IsDBNull(10) ? null : Parse(r.GetString(10)),
DecidedBy = r.IsDBNull(11) ? null : r.GetString(11),
ResultRef = r.IsDBNull(12) ? null : r.GetString(12),
RejectionReason = r.IsDBNull(13) ? null : r.GetString(13)
RejectionReason = r.IsDBNull(13) ? null : r.GetString(13),
Approver = r.IsDBNull(14) ? null : r.GetString(14),
ApproverUntil = r.IsDBNull(15) ? null : Parse(r.GetString(15)),
ApproverTaskId = r.IsDBNull(16) ? null : r.GetString(16)
};
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
+22 -4
View File
@@ -52,6 +52,12 @@ public sealed class StagingGate
_clock = clock ?? TimeProvider.System;
}
/// <summary>
/// #687: Hands a freshly staged call to the lead that may decide it. Returns the line the
/// agent reads, or <c>null</c> when a human decides.
/// </summary>
public Func<StagedCall, CancellationToken, Task<string?>>? Route { get; set; }
/// <summary>Verwirft offene Vorschlaege, die aelter als die Verfallsfrist sind. Gibt die Anzahl zurueck.</summary>
public Task<int> ExpireAsync(CancellationToken ct)
{
@@ -115,7 +121,7 @@ public sealed class StagingGate
vorhanden.Id);
}
var id = await _repo.AppendAsync(new StagedCall
var staged = new StagedCall
{
RunId = runId,
AgentId = agentId,
@@ -124,13 +130,25 @@ public sealed class StagingGate
Action = action,
ArgumentsJson = argumentsJson,
Proposal = BuildProposal(tool, action, argumentsJson)
}, ct);
};
var id = await _repo.AppendAsync(staged, ct);
// #687: A lead within its unit's limits decides instead of the human.
string? beiLeitung = null;
if (Route is { } route)
{
// Routing is a relief for the human, not a condition: if it fails, the human decides.
try { beiLeitung = await route(staged with { Id = id }, ct); }
catch (Exception ex) when (ex is not OperationCanceledException) { beiLeitung = null; }
}
return new StagingInterception(
StagingOutcome.Staged,
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. {einstufung.Grund} " +
"Die Aktion wird erst nach menschlicher Freigabe ausgeführt. Scheitert sie oder wird sie " +
"abgelehnt, wirst du geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
(beiLeitung is null
? "Die Aktion wird erst nach menschlicher Freigabe ausgeführt. "
: $"Die Aktion wird erst nach Freigabe ausgeführt. {beiLeitung} ") +
"Scheitert sie oder wird sie abgelehnt, wirst du geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
id);
default:
@@ -73,6 +73,21 @@ public sealed record StagedCall
/// <summary>Bei Ablehnung: der Grund.</summary>
public string? RejectionReason { get; init; }
/// <summary>
/// #687: The lead that may decide this call instead of the human, until
/// <see cref="ApproverUntil"/>. The human can decide it all the time; after that only the human.
/// </summary>
public string? Approver { get; init; }
public DateTime? ApproverUntil { get; init; }
/// <summary>#687: The task in the lead's inbox — closed once the call is decided.</summary>
public string? ApproverTaskId { get; init; }
/// <summary>#687: Whether the lead may still decide it at <paramref name="nowUtc"/>.</summary>
public bool WithLead(DateTime nowUtc)
=> Status == StagingStatus.Pending && !string.IsNullOrWhiteSpace(Approver) && ApproverUntil is { } until && nowUtc < until;
}
public interface IStagingRepository
@@ -116,4 +131,10 @@ public interface IStagingRepository
/// <summary>#660: Everything a run proposed for approval - for the run detail view.</summary>
Task<IReadOnlyList<StagedCall>> ListForRunAsync(string runId, CancellationToken ct);
/// <summary>#687: Hands a pending call to a lead until a point in time, with its inbox task.</summary>
Task SetApproverAsync(long id, string approver, DateTime until, string taskId, CancellationToken ct);
/// <summary>#687: Calls routed to a lead since <paramref name="since"/> — decided or not, for its approval rate and daily cap.</summary>
Task<IReadOnlyList<StagedCall>> ListRoutedSinceAsync(DateTime since, CancellationToken ct);
}
+27 -1
View File
@@ -141,6 +141,7 @@ public sealed class StagingService
// ausgeführt wird.
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, decidedBy, null, DateTime.UtcNow, ct))
return StagingResult.AlreadyDecided;
await CloseLeadTaskAsync(call, decidedBy, ct);
string result;
StagingStatus final;
@@ -202,6 +203,7 @@ public sealed class StagingService
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, decidedBy, reason, DateTime.UtcNow, ct))
return StagingResult.AlreadyDecided;
await CloseLeadTaskAsync(call, decidedBy, ct);
await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct);
await SammelnAsync(call, "abgelehnt", $"Grund: {reason}", weckt: true, ct);
@@ -211,6 +213,30 @@ public sealed class StagingService
// ─── Hilfsfunktionen ───
/// <summary>
/// #687: Once a call is decided — by its lead or by the human first — the lead's task for
/// it is done; a lead must not be woken for something already settled.
/// </summary>
private async Task CloseLeadTaskAsync(StagedCall call, string decidedBy, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(call.ApproverTaskId))
return;
try
{
if (await _board.GetAsync(call.ApproverTaskId, ct) is { Status: not (TaskItemStatus.Done or TaskItemStatus.Canceled) } task)
{
var byLead = decidedBy.StartsWith(LeadApprovals.DecidedByPrefix, StringComparison.Ordinal);
if (!byLead)
await _board.AddCommentAsync(task.Id, "system", $"Approval #{call.Id} was decided by a human ({decidedBy}) — nothing left to do.", ct);
await _board.UpdateAsync(task.Id, t => t with { Status = byLead ? TaskItemStatus.Done : TaskItemStatus.Canceled }, ct);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "The lead's task for approval #{Id} could not be closed", call.Id);
}
}
/// <summary>
/// Trägt eine Entscheidung in die offene Sammelaufgabe des Agenten ein und schiebt ihren
/// Termin um das Bündelfenster nach hinten. Gibt es keine offene, entsteht eine — aber
@@ -267,7 +293,7 @@ public sealed class StagingService
}
internal const string SammelKopf =
"[Freigabe-Ergebnisse] Ein Mensch hat über Aktionen entschieden, die du zur Freigabe "
"[Freigabe-Ergebnisse] Ein Mensch oder deine Leitung hat über Aktionen entschieden, die du zur Freigabe "
+ "vorgelegt hattest. Freigegebene Aktionen wurden genau so ausgeführt, wie du sie vorgelegt "
+ "hast, abgelehnte gar nicht.\n"
+ "- Gescheitert: Prüfe die Ursache. Liegt sie bei dir (falscher Name, falsche Spalte, "
@@ -491,6 +491,10 @@ public sealed class SqliteStorage
SpalteErgaenzen(connection, "RunUsage", "Runtime", "TEXT NOT NULL DEFAULT ''");
SpalteErgaenzen(connection, "RunUsage", "OnSubscription", "INTEGER NOT NULL DEFAULT 0");
SpalteErgaenzen(connection, "RunReceipts", "BudgetPot", "TEXT NULL");
// #687: a lead that may decide a staged call instead of the human, until when, and its inbox task.
SpalteErgaenzen(connection, "StagedCalls", "Approver", "TEXT NULL");
SpalteErgaenzen(connection, "StagedCalls", "ApproverUntil", "TEXT NULL");
SpalteErgaenzen(connection, "StagedCalls", "ApproverTaskId", "TEXT NULL");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand();
@@ -49,6 +49,9 @@ public sealed record AgentToolContext(
/// <summary>#689: The desk for escalations, conflicts and decisions. <c>null</c> outside the engine.</summary>
public Cases.ICaseDesk? Cases { get; init; }
/// <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>#654: Where code runs — never in the main process. Unavailable under Windows.</summary>
public Sandbox.ISandboxRunner? Sandbox { get; init; }
@@ -573,6 +573,16 @@ button.klein {
background: var(--surface-hover);
}
/* #687: a lead that approves everything */
.daten-tabelle tr.zeile-warnung td {
color: var(--warnung);
}
/* #687: a call a lead may still decide — the human's too, but not yet the human's alone */
.staging-bei-leitung {
border-left: 3px solid var(--accent);
}
/* ─── Datenlisten (dl/dt/dd) ─── */
.datenliste {
@@ -95,6 +95,11 @@
"org.ceiling": "Rechte-Obergrenze",
"org.ceilingHint": "Was die Einheit nicht darf, kann ein Mitglied nicht bekommen. Senken entzieht den Mitgliedern Werkzeuge — nach einer Bestätigung.",
"org.ceilingLimit": "Werkzeuge dieser Einheit begrenzen",
"org.approvals": "Freigaben durch die Leitung",
"org.approvalsHint": "Was die Leitung dieser Einheit für die Agenten darunter an deiner Stelle freigeben darf. Eine Operation je Zeile: Werkzeug.operation, oder Werkzeug für alle. Änderungen an Agenten bleiben immer bei dir; eine Leitung gibt nie ihre eigenen Aufrufe frei. Eine Untereinheit darf nicht mehr freigeben als die Einheiten darüber. Leer: du entscheidest alles.",
"org.approvalsOperations": "Operationen",
"org.approvalsPerDay": "Höchstens je Tag",
"org.approvalsHours": "Stunden je Freigabe",
"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.",
@@ -608,6 +613,16 @@
"staging.rejectButton": "Aufruf ablehnen",
"staging.rejected": "Aufruf wurde abgelehnt.",
"staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}",
"staging.withLead": "Die Leitung {lead} darf dies bis {until} entscheiden, im Rahmen dessen, was ihre Einheit erlaubt. Du kannst vorher entscheiden; danach liegt es allein bei dir.",
"staging.leadsTitle": "Freigaben durch Leitungen",
"staging.leadsHint": "Was Leitungen in den letzten 30 Tagen an deiner Stelle freigegeben haben. Eine Leitung, die alles freigibt, verursacht nur Kosten — sieh dir ihre Entscheidungen an.",
"staging.leadName": "Leitung",
"staging.leadRouted": "Vorgelegt",
"staging.leadApproved": "Freigegeben",
"staging.leadRejected": "Abgelehnt",
"staging.leadLapsed": "Dir überlassen",
"staging.leadRate": "Freigabequote",
"staging.leadAllApproved": "Alles freigegeben — prüfe, ob diese Leitung wirklich hinsieht.",
"agentChats.title": "Agenten-Kommunikation (A2A)",
"agentChats.allAgents": "Alle Agenten",
@@ -95,6 +95,11 @@
"org.ceiling": "Tool ceiling",
"org.ceilingHint": "What the unit may not have, a member cannot be granted. Lowering it removes tools from the members — after a confirmation.",
"org.ceilingLimit": "Limit the tools of this unit",
"org.approvals": "Approvals by the lead",
"org.approvalsHint": "What the lead of this unit may approve for the agents below it, instead of you. One operation per line: Tool.operation, or Tool for all of it. Changes to agents always stay with you; a lead never approves its own calls. A sub-unit can grant no more than the units above it. Empty: you decide everything.",
"org.approvalsOperations": "Operations",
"org.approvalsPerDay": "Per day at most",
"org.approvalsHours": "Hours per approval",
"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.",
@@ -608,6 +613,16 @@
"staging.rejectButton": "Reject Call",
"staging.rejected": "Call was rejected.",
"staging.rejectFailed": "Rejection failed: {error}",
"staging.withLead": "Its lead {lead} may decide this until {until}, within the limits its unit grants. You can decide it first; after that it is yours alone.",
"staging.leadsTitle": "Approvals by leads",
"staging.leadsHint": "What leads approved instead of you in the last 30 days. A lead that approves everything does nothing but cost — look at its decisions.",
"staging.leadName": "Lead",
"staging.leadRouted": "Given",
"staging.leadApproved": "Approved",
"staging.leadRejected": "Rejected",
"staging.leadLapsed": "Left to you",
"staging.leadRate": "Approval rate",
"staging.leadAllApproved": "Everything approved — check whether this lead really looks.",
"agentChats.title": "Agent Communication (A2A)",
"agentChats.allAgents": "All Agents",
@@ -366,6 +366,7 @@ function unitPanel(unit) {
cards.push(unitWorkspaceCard(unit));
cards.push(ceilingCard(unit));
cards.push(approvalsCard(unit));
cards.push(settingsCard(unit));
// Members.
@@ -432,6 +433,35 @@ function ceilingCard(unit) {
]);
}
/**
* #687: What the unit's lead may approve instead of the human. One operation per line —
* "Tool.operation", or "Tool" for all of it. Changes to agents are refused by the server.
*/
function approvalsCard(unit) {
const current = unit.approvals;
const ops = erstelleElement("textarea", { rows: 3, placeholder: "Database.insert\nFileRW.delete" });
ops.value = (current?.operations ?? []).join("\n");
const perDay = erstelleElement("input", { type: "number", min: "0", step: "1", value: String(current?.perDay ?? 20) });
const hours = erstelleElement("input", { type: "number", min: "0.25", step: "0.25", value: String(current?.hours ?? 4) });
return erstelleKarte(t("org.approvals"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.approvalsHint") }),
erstelleElement("div", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.approvalsOperations") }), ops,
erstelleElement("label", { text: t("org.approvalsPerDay") }), perDay,
erstelleElement("label", { text: t("org.approvalsHours") }), hours),
erstelleElement("button", {
type: "button", klasse: "klein", text: t("common.save"),
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/approvals`, "PUT", {
operations: ops.value.split(/[\n,]/).map((o) => o.trim()).filter(Boolean),
perDay: Number(perDay.value),
hours: Number(hours.value),
reason: reason()
})
})
]);
}
function settingsCard(unit) {
const setting = erstelleElement("select", {},
...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) })));
@@ -16,10 +16,15 @@ import {
let wartendeAufrufe = [];
// #687: What a lead may still decide is not yet the human's to do — it is shown, not counted.
function menschWartet(anfrage) {
return !anfrage.withLead;
}
export async function holeWartendeAnzahl() {
try {
const liste = await api("/staging");
return Array.isArray(liste) ? liste.length : 0;
return Array.isArray(liste) ? liste.filter(menschWartet).length : 0;
} catch {
return 0;
}
@@ -48,7 +53,8 @@ export async function rendereStaging() {
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
const dauerBereich = erstelleElement("div", { id: "staging-dauer-bereich" });
layout.append(kopfZeile, listeBereich, dauerBereich);
const leitungBereich = erstelleElement("div", { id: "staging-leitung-bereich" });
layout.append(kopfZeile, listeBereich, dauerBereich, leitungBereich);
container.append(layout);
await ladeFreigaben();
@@ -67,8 +73,9 @@ async function ladeFreigaben() {
container.replaceChildren();
// Aktualisiere Badge in Sidebar
aktualisiereStagingBadge(wartendeAufrufe.length);
aktualisiereStagingBadge(wartendeAufrufe.filter(menschWartet).length);
ladeDauerfreigaben();
ladeLeitungen();
if (!wartendeAufrufe || wartendeAufrufe.length === 0) {
container.append(
@@ -137,6 +144,14 @@ async function ladeFreigaben() {
const teile = [datenliste, argBox];
// #687: A lead decides it within its unit's limits — the human can still decide first.
if (anfrage.withLead) {
teile.unshift(erstelleElement("div", {
klasse: "hinweis-box",
text: t("staging.withLead", { lead: anfrage.withLead, until: formatiereDatumZeit(anfrage.leadUntil) })
}));
}
if (anfrage.suggestAllowSimilar) {
teile.push(
erstelleElement("div", {
@@ -169,7 +184,8 @@ async function ladeFreigaben() {
}
const karte = erstelleKarte(`${anfrage.tool}.${anfrage.action || "?"}`, teile, {
aktionen: kopfAktionen
aktionen: kopfAktionen,
klasse: anfrage.withLead ? "staging-bei-leitung" : undefined
});
container.append(karte);
@@ -226,6 +242,44 @@ async function bestaetigeFreigabe(id, gleichartig = false) {
}
}
/**
* #687: How each lead decided what it was given. A lead that approves everything does
* nothing but cost — the rate is the finding, so it is shown, not hidden in the audit.
*/
async function ladeLeitungen() {
const bereich = document.getElementById("staging-leitung-bereich");
if (!bereich) return;
let liste = [];
try { liste = await api("/staging/leads"); } catch { liste = []; }
if (!Array.isArray(liste) || liste.length === 0) {
bereich.replaceChildren();
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
tabelle.append(erstelleElement("thead", {}, erstelleElement("tr", {},
...["staging.leadName", "staging.leadRouted", "staging.leadApproved", "staging.leadRejected", "staging.leadLapsed", "staging.leadRate"]
.map((k) => erstelleElement("th", { text: t(k) })))));
const koerper = erstelleElement("tbody");
for (const l of liste) {
const quote = l.approvalRate == null ? "—" : `${Math.round(l.approvalRate * 100)} %`;
// Everything approved, with enough decisions to mean something, is worth a look.
const auffaellig = l.approvalRate === 1 && l.approved >= 5;
koerper.append(erstelleElement("tr", { klasse: auffaellig ? "zeile-warnung" : undefined },
erstelleElement("td", { text: l.leadId }),
erstelleElement("td", { text: String(l.routed) }),
erstelleElement("td", { text: String(l.approved) }),
erstelleElement("td", { text: String(l.rejected) }),
erstelleElement("td", { text: String(l.lapsed) }),
erstelleElement("td", { text: quote, title: auffaellig ? t("staging.leadAllApproved") : "" })));
}
tabelle.append(koerper);
bereich.replaceChildren(erstelleKarte(t("staging.leadsTitle"), [
erstelleElement("p", { klasse: "gedaempft", text: t("staging.leadsHint") }),
tabelle
]));
}
/**
* #641: Die geltenden Dauerfreigaben - "Gleichartiges kuenftig erlauben" - mit Widerruf.
*/
@@ -31,6 +31,7 @@ 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.
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.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
@@ -39,7 +40,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"]
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call"]
},
"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." },
@@ -54,7 +55,9 @@ public sealed class EscalationTool : IAgentTool
"note": { "type": "string", "description": "handle: what you did about it." },
"assessment": { "type": "string", "description": "forward: your view, added to the case." },
"with": { "type": "string", "description": "open_conflict: the agent you disagree with." },
"position": { "type": "string", "description": "open_conflict, state_position: your side, with reasons." }
"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." }
},
"required": ["action"]
}
@@ -70,6 +73,10 @@ public sealed class EscalationTool : IAgentTool
if (Get(input, "action") == "request_budget")
return await SetUpBudgetAsync(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);
if (context.Cases is not { } desk)
return ToolResult.Fail("Escalation is not available in this instance.");
@@ -138,7 +145,7 @@ public sealed class EscalationTool : IAgentTool
break;
default:
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget.");
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call.");
}
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
@@ -196,6 +203,30 @@ public sealed class EscalationTool : IAgentTool
"Runs in that task tree draw on it, not on your daily budget; once it is used up, they count against the day again.");
}
private static async Task<ToolResult> ApprovalAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (context.Approvals is not { } approvals)
return ToolResult.Fail("Approvals by leads are not available in this instance.");
var action = Get(input, "action");
if (action == "list_approvals")
{
var waiting = await approvals.PendingForAsync(context.AgentId, ct);
if (waiting.Count == 0)
return ToolResult.Ok("No approval waits for you.");
var sb = new StringBuilder($"{waiting.Count} approval(s) wait for you:\n");
foreach (var c in waiting)
sb.AppendLine($"- #{c.Id} {c.Tool}{(string.IsNullOrWhiteSpace(c.Action) ? "" : "." + c.Action)} from {c.AgentId}, " +
$"until {c.ApproverUntil:yyyy-MM-dd HH:mm} UTC — task {c.ApproverTaskId}");
return ToolResult.Ok(sb.ToString().TrimEnd());
}
if (!input.TryGetProperty("call", out var n) || !n.TryGetInt64(out var callId))
return ToolResult.Fail("'call' is required — the approval number.");
var decided = await approvals.DecideAsync(callId, context.AgentId, context.Trust, action == "approve_call", Get(input, "reason") ?? "", ct);
return decided.Ok ? ToolResult.Ok(decided.Message) : ToolResult.Fail(decided.Message);
}
private static string Describe(Decision d)
=> CaseDesk.DecisionLine(d) + (string.IsNullOrWhiteSpace(d.Reasoning) ? "" : $"\n Reasoning: {d.Reasoning}");
+7 -1
View File
@@ -307,7 +307,13 @@ public sealed record StagedCallDto(
DateTime CreatedAt,
int PreviousApprovals = 0,
bool CanAllowSimilar = false,
bool SuggestAllowSimilar = false);
bool SuggestAllowSimilar = false,
// #687: the lead that may decide it instead of the human, and until when.
string? WithLead = null,
DateTime? LeadUntil = null);
/// <summary>#687: How a lead decided the approvals it was given.</summary>
public sealed record LeadApprovalStatsDto(string LeadId, int Routed, int Approved, int Rejected, int Lapsed, double? ApprovalRate);
/// <summary>#641: Eine Dauerfreigabe - "Gleichartiges kuenftig erlauben".</summary>
public sealed record StandingApprovalDto(
+4 -1
View File
@@ -220,7 +220,10 @@ internal static class Mapping
call.CreatedAt,
bisherFreigegeben,
erlaubbar,
erlaubbar && bisherFreigegeben >= StagingService.VorschlagAb);
erlaubbar && bisherFreigegeben >= StagingService.VorschlagAb,
// #687: with a lead until then — the human can still decide it first.
call.WithLead(DateTime.UtcNow) ? call.Approver : null,
call.WithLead(DateTime.UtcNow) ? call.ApproverUntil : null);
}
public static StandingApprovalDto ToDto(this Dauerfreigabe d)
@@ -18,7 +18,13 @@ public sealed record UnitDto(
IReadOnlyList<string>? EffectiveToolCeiling,
UnitDefaultsDto Defaults,
string Path,
int Depth);
int Depth,
// #687: what the lead may approve instead of the human; null: nothing.
UnitApprovalsDto? Approvals = null);
public sealed record UnitApprovalsDto(IReadOnlyList<string> Operations, int PerDay, double Hours);
public sealed record ApprovalsInputDto(IReadOnlyList<string>? Operations, int? PerDay, double? Hours, string Reason = "");
public sealed record UnitDefaultsDto(string? Model, decimal? DailyCostUsd, long? DailyTokens, string? WhenExhausted);
@@ -365,6 +365,15 @@ internal static class ApiEndpoints
result.Kind is Core.Staging.StagingResultKind.Executed, result.Message));
});
// #687: How often each lead approved what it was given — a lead that approves everything does nothing.
staging.MapGet("/leads", async (AppHost host, CancellationToken ct, int days = 30) =>
{
if (host.Engine?.LeadApprovals is not { } leads)
return Results.Ok(Array.Empty<LeadApprovalStatsDto>());
var stats = await leads.StatsAsync(days, ct);
return Results.Ok(stats.Select(x => new LeadApprovalStatsDto(x.LeadId, x.Routed, x.Approved, x.Rejected, x.Lapsed, x.ApprovalRate)).ToList());
});
staging.MapGet("/standing", async (AppHost host, CancellationToken ct, bool revoked = false) =>
{
if (host.Staging is not { } service)
@@ -27,7 +27,8 @@ internal static class OrganisationEndpoints
u.Id, u.Name, u.ParentId, u.LeaderAgentId, u.Goal, u.ToolCeiling,
chart.ToolCeiling(u.Id)?.Order(StringComparer.OrdinalIgnoreCase).ToList(),
new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted),
chart.PathOf(u.Id), chart.Depth(u.Id)))
chart.PathOf(u.Id), chart.Depth(u.Id),
u.Approvals is { } ap ? new UnitApprovalsDto(ap.Operations, ap.PerDay, ap.Hours) : null))
.ToList();
var agents = host.Instance.Agents
@@ -80,6 +81,10 @@ internal static class OrganisationEndpoints
? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target)))
: host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse());
// #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());
org.MapPut("/units/{unitId}/ceiling", (AppHost host, string unitId, CeilingInputDto input) =>
input.Preview
? Results.Ok(ToDto(host.Organisation.PreviewToolCeiling(unitId, input.Tools)))
@@ -251,6 +251,43 @@ public sealed class OrgAdministrationTests : IDisposable
Find("newbie").UnitId.ShouldBe("amazon");
}
// ═══════════════════ approvals by the lead (#687) ═══════════════════
private UnitConfig Unit(string id) => _test.Host.Instance.Units.Single(u => u.Id == id);
[Fact]
public void A_unit_grants_its_lead_approvals_but_never_changes_to_agents()
{
BuildMarketing();
Org.SetApprovals("marketing", ["AgentEditor.update_soul"]).Message.ShouldContain("always stays with a human");
Org.SetApprovals("marketing", ["AgentEditor"]).Ok.ShouldBeFalse("a whole tool with such an operation reads as a grant for it");
Org.SetApprovals("marketing", [".x"]).Ok.ShouldBeFalse();
var set = Org.SetApprovals("marketing", ["Database.insert", "FileRW.delete"], perDay: 5, hours: 2, reason: "night shift");
set.Ok.ShouldBeTrue(set.Message);
Unit("marketing").Approvals!.Operations.ShouldBe(["Database.insert", "FileRW.delete"]);
Unit("marketing").Approvals!.PerDay.ShouldBe(5);
Org.SetApprovals("marketing", []).Ok.ShouldBeTrue();
Unit("marketing").Approvals.ShouldBeNull();
}
[Fact]
public void A_sub_unit_grants_no_more_than_the_units_above_and_shrinks_with_them()
{
BuildMarketing();
Org.SetApprovals("marketing", ["Database"]).Ok.ShouldBeTrue();
Org.SetApprovals("amazon", ["Mail.send"]).Message.ShouldContain("Marketing");
Org.SetApprovals("amazon", ["Database.insert", "Database.update"]).Ok.ShouldBeTrue("'Database' above covers each of its operations");
var lowered = Org.SetApprovals("marketing", ["Database.insert"]);
lowered.Message.ShouldContain("Amazon");
Unit("amazon").Approvals!.Operations.ShouldBe(["Database.insert"]);
}
// ═══════════════════ the top (#690) ═══════════════════
private void MakeDecider(string id)
@@ -75,6 +75,20 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
}
}
public Task SetApproverAsync(long id, string approver, DateTime until, string taskId, CancellationToken ct)
{
lock (_lock)
{
if (_calls.TryGetValue(id, out var call) && call.Status == StagingStatus.Pending)
_calls[id] = call with { Approver = approver, ApproverUntil = until, ApproverTaskId = taskId };
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<StagedCall>> ListRoutedSinceAsync(DateTime since, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<StagedCall>>(
_calls.Values.Where(c => c.Approver is not null && c.CreatedAt >= since).OrderBy(c => c.Id).ToList());
/// <summary>Verschiebt den Anlagezeitpunkt eines Vorschlags in die Vergangenheit - fuer Verfallstests.</summary>
public void Altern(long id, TimeSpan um)
{
@@ -0,0 +1,223 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Staging;
/// <summary>
/// #687: A lead approves within the limits its unit grants — never its own calls, never a
/// change to agents, never while it reads foreign content — and the human stays above it.
/// </summary>
public sealed class LeadApprovalsTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "leads-" + Guid.NewGuid().ToString("N")[..8]);
private readonly SqliteStagingRepository _staging;
private readonly SqliteTaskRepository _tasks;
private readonly TaskboardService _board;
private readonly FakeTimeProvider _clock = new(DateTimeOffset.UtcNow);
private readonly Executor _executor = new();
private readonly StagingService _service;
private readonly LeadApprovals _leads;
private readonly HashSet<string> _foreign = new(StringComparer.OrdinalIgnoreCase);
/// <summary>ops (ops-lead; grants Database.insert, 2 a day, 1 h) › team (team-lead, grants nothing; worker).</summary>
private readonly UnitConfig _ops = new()
{
Id = "ops", Name = "Operations", LeaderAgentId = "ops-lead",
Approvals = new UnitApprovals { Operations = ["Database.insert", "AgentEditor"], PerDay = 2, Hours = 1 }
};
private OrgChart Org => new(
[_ops, new UnitConfig { Id = "team", Name = "Team", ParentId = "ops", LeaderAgentId = "team-lead" }],
[
new AgentConfig { AgentId = "ops-lead", UnitId = "ops" },
new AgentConfig { AgentId = "team-lead", UnitId = "team" },
new AgentConfig { AgentId = "worker", UnitId = "team" }
]);
public LeadApprovalsTests()
{
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_staging = new SqliteStagingRepository(storage);
_tasks = new SqliteTaskRepository(storage);
_board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks"));
_service = new StagingService(_staging, _executor, _board, NullLoggerFactory.Instance);
_leads = new LeadApprovals(_staging, _board, () => Org,
a => _foreign.Contains(a.AgentId) ? new AgentTrust(TrustTier.ReadsForeignContent, ["WebFetch"]) : AgentTrust.Standard,
NullLogger.Instance, _clock) { Service = _service };
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private async Task<(StagedCall Call, string? Line)> Stage(string agent, string tool = "Database", string action = "insert")
{
var call = new StagedCall { RunId = "run-1", AgentId = agent, Tool = tool, Action = action, ArgumentsJson = $"{{\"action\":\"{action}\"}}" };
var id = await _staging.AppendAsync(call, default);
var line = await _leads.RouteAsync(call with { Id = id }, default);
return ((await _staging.GetAsync(id, default))!, line);
}
// ═══════════════════ where it goes ═══════════════════
[Fact]
public async Task A_call_goes_to_the_nearest_lead_whose_unit_grants_it()
{
var (call, line) = await Stage("worker");
call.Approver.ShouldBe("ops-lead", "team-lead's unit grants nothing — the grant above counts");
line!.ShouldContain("ops-lead");
var inbox = (await _tasks.GetAsync(call.ApproverTaskId!, default))!;
inbox.Assignee.ShouldBe("@ops-lead");
inbox.Body.ShouldContain("<untrusted_content", Case.Sensitive, customMessage: "the frozen call is data for the lead");
inbox.Body.ShouldContain("approve_call");
}
[Fact]
public async Task What_the_unit_does_not_grant_goes_to_the_human()
=> (await Stage("worker", "Mail", "send")).Call.Approver.ShouldBeNull();
[Fact]
public async Task A_change_to_agents_stays_with_the_human_even_under_a_whole_tool_grant()
=> (await Stage("worker", "AgentEditor", "update_soul")).Call.Approver.ShouldBeNull();
[Fact]
public async Task A_lead_never_gets_its_own_call()
{
(await Stage("ops-lead")).Call.Approver.ShouldBeNull("nobody above ops-lead grants anything");
(await Stage("team-lead")).Call.Approver.ShouldBe("ops-lead");
}
[Fact]
public async Task A_lead_that_reads_foreign_content_does_not_approve()
{
_foreign.Add("ops-lead");
(await Stage("worker")).Call.Approver.ShouldBeNull();
}
[Fact]
public async Task Beyond_the_daily_cap_the_human_decides()
{
(await Stage("worker")).Call.Approver.ShouldBe("ops-lead");
(await Stage("worker")).Call.Approver.ShouldBe("ops-lead");
(await Stage("worker")).Call.Approver.ShouldBeNull();
}
// ═══════════════════ how it is decided ═══════════════════
[Fact]
public async Task The_lead_approves_the_frozen_call_and_the_decision_is_marked_as_the_leads()
{
var (call, _) = await Stage("worker");
var decided = await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: true, "", default);
decided.Ok.ShouldBeTrue(decided.Message);
_executor.Calls.ShouldBe(1);
var after = (await _staging.GetAsync(call.Id, default))!;
after.Status.ShouldBe(StagingStatus.Executed);
after.DecidedBy.ShouldBe("lead:ops-lead");
(await _tasks.GetAsync(call.ApproverTaskId!, default))!.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task A_rejection_needs_its_reason()
{
var (call, _) = await Stage("worker");
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: false, " ", default)).Ok.ShouldBeFalse();
var rejected = await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: false, "Wrong table.", default);
rejected.Ok.ShouldBeTrue(rejected.Message);
(await _staging.GetAsync(call.Id, default))!.Status.ShouldBe(StagingStatus.Rejected);
_executor.Calls.ShouldBe(0);
}
[Fact]
public async Task Only_the_routed_lead_decides_and_only_in_time()
{
var (call, _) = await Stage("worker");
(await _leads.DecideAsync(call.Id, "team-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("not yours");
(await _leads.DecideAsync(call.Id, "ops-lead", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"]), true, "", default))
.Message.ShouldContain("outside");
_clock.Advance(TimeSpan.FromHours(1.5));
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("run out");
(await _leads.PendingForAsync("ops-lead", default)).ShouldBeEmpty();
(await _service.ApproveAsync(call.Id, "webui", default)).Kind.ShouldBe(StagingResultKind.Executed, "the human decides it now");
}
[Fact]
public async Task When_the_human_decides_first_the_leads_task_is_closed()
{
var (call, _) = await Stage("worker");
await _service.RejectAsync(call.Id, "webui", "Not now.", default);
(await _tasks.GetAsync(call.ApproverTaskId!, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("already decided");
}
[Fact]
public async Task A_grant_taken_away_ends_the_leads_say()
{
var (call, _) = await Stage("worker");
_ops.Approvals = null;
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("no longer grants");
}
// ═══════════════════ the rate ═══════════════════
[Fact]
public async Task The_approval_rate_per_lead_is_counted()
{
var (first, _) = await Stage("worker");
var (second, _) = await Stage("worker");
await _leads.DecideAsync(first.Id, "ops-lead", AgentTrust.Standard, true, "", default);
await _leads.DecideAsync(second.Id, "ops-lead", AgentTrust.Standard, false, "No.", default);
var stats = (await _leads.StatsAsync(30, default)).Single();
stats.LeadId.ShouldBe("ops-lead");
stats.Routed.ShouldBe(2);
stats.Approved.ShouldBe(1);
stats.Rejected.ShouldBe(1);
stats.ApprovalRate.ShouldBe(0.5);
}
[Fact]
public async Task The_gate_tells_the_agent_who_decides()
{
var gate = new StagingGate(new StagingPolicy(), _staging) { Route = _leads.RouteAsync };
var intercept = await gate.InterceptAsync("worker", "inst", "run-2", "Database", "{\"action\":\"insert\",\"table\":\"orders\"}", default, "insert");
intercept.Outcome.ShouldBe(StagingOutcome.Staged);
intercept.Message.ShouldContain("ops-lead");
intercept.Message.ShouldNotContain("menschlicher Freigabe");
}
private sealed class Executor : IFrozenCallExecutor
{
public int Calls { get; private set; }
public Task<string> ExecuteApprovedCallAsync(string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
{
Calls++;
return Task.FromResult("1 row inserted");
}
}
}