Compare commits
2
Commits
dafb8241a0
...
305b177650
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
305b177650 | ||
|
|
f51186269c |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:<id></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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user