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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dafb8241a0
commit
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
|
Agenten kann ein Mensch die Läufe lesen. Bei fünfzig muss etwas anderes ihm sagen, wo
|
||||||
er hinsehen soll.
|
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
|
## 10a — Das Lagebild
|
||||||
|
|||||||
@@ -642,7 +642,7 @@ public sealed class AppHost : IAsyncDisposable
|
|||||||
|
|
||||||
var (scanner, staging, board) = BuildTaskboard(
|
var (scanner, staging, board) = BuildTaskboard(
|
||||||
instance, taskRepository, engine, tools, stateStore, audit,
|
instance, taskRepository, engine, tools, stateStore, audit,
|
||||||
stagingRepository, dauerfreigaben, cases, loggerFactory, logger);
|
stagingRepository, dauerfreigaben, stagingGate, cases, loggerFactory, logger);
|
||||||
|
|
||||||
var host = new AppHost
|
var host = new AppHost
|
||||||
{
|
{
|
||||||
@@ -800,6 +800,7 @@ public sealed class AppHost : IAsyncDisposable
|
|||||||
SqliteAuditRepository audit,
|
SqliteAuditRepository audit,
|
||||||
SqliteStagingRepository stagingRepository,
|
SqliteStagingRepository stagingRepository,
|
||||||
SqliteDauerfreigabeRepository dauerfreigaben,
|
SqliteDauerfreigabeRepository dauerfreigaben,
|
||||||
|
StagingGate stagingGate,
|
||||||
Core.Cases.ICaseRepository cases,
|
Core.Cases.ICaseRepository cases,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
@@ -816,6 +817,14 @@ public sealed class AppHost : IAsyncDisposable
|
|||||||
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit,
|
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit,
|
||||||
dauerfreigaben: dauerfreigaben);
|
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(
|
var dispatcher = new EngineTaskDispatcher(
|
||||||
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory,
|
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory,
|
||||||
tasks: taskRepository);
|
tasks: taskRepository);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Audit;
|
|||||||
using ClawdDotNet.Core.Cases;
|
using ClawdDotNet.Core.Cases;
|
||||||
using ClawdDotNet.Core.Config;
|
using ClawdDotNet.Core.Config;
|
||||||
using ClawdDotNet.Core.Organisation;
|
using ClawdDotNet.Core.Organisation;
|
||||||
|
using ClawdDotNet.Core.Staging;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ClawdDotNet.App.Services;
|
namespace ClawdDotNet.App.Services;
|
||||||
@@ -201,6 +202,84 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
|
|||||||
return Preview([agentId], NullIfEmpty(targetUnitId), movingUnit: null, problems);
|
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) ───
|
// ─── the top (#690) ───
|
||||||
|
|
||||||
private bool IsTopAgent(string agentId)
|
private bool IsTopAgent(string agentId)
|
||||||
@@ -806,7 +885,7 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
|
|||||||
private static UnitConfig Clone(UnitConfig u) => new()
|
private static UnitConfig Clone(UnitConfig u) => new()
|
||||||
{
|
{
|
||||||
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
|
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);
|
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>
|
/// <summary>Starting values for agents that join this unit.</summary>
|
||||||
[JsonPropertyName("defaults")]
|
[JsonPropertyName("defaults")]
|
||||||
public UnitDefaults Defaults { get; set; } = new();
|
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>
|
/// <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>
|
/// <summary>#689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context.</summary>
|
||||||
public Cases.ICaseDesk? CaseDesk { get; set; }
|
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>
|
/// <summary>
|
||||||
/// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the
|
/// #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.
|
/// daily limits; without this, every run counts against the day.
|
||||||
@@ -1864,6 +1867,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
Organisation = CurrentOrganisation(),
|
Organisation = CurrentOrganisation(),
|
||||||
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
|
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
|
||||||
Cases = CaseDesk,
|
Cases = CaseDesk,
|
||||||
|
Approvals = LeadApprovals,
|
||||||
SpecialBudgets = SpecialBudgets,
|
SpecialBudgets = SpecialBudgets,
|
||||||
Sandbox = Sandbox,
|
Sandbox = Sandbox,
|
||||||
Files = Files,
|
Files = Files,
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ public static class OrgBriefing
|
|||||||
if (org.LedBy(agentId) is { } led)
|
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.");
|
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);
|
var subUnits = org.Children(led.Id);
|
||||||
if (subUnits.Count > 0)
|
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);
|
return await cmd.ExecuteNonQueryAsync(ct);
|
||||||
}, 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 ───
|
// ─── Hilfsfunktionen ───
|
||||||
|
|
||||||
private const string Columns =
|
private const string Columns =
|
||||||
"Id, RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, " +
|
"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()
|
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)),
|
DecidedAt = r.IsDBNull(10) ? null : Parse(r.GetString(10)),
|
||||||
DecidedBy = r.IsDBNull(11) ? null : r.GetString(11),
|
DecidedBy = r.IsDBNull(11) ? null : r.GetString(11),
|
||||||
ResultRef = r.IsDBNull(12) ? null : r.GetString(12),
|
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");
|
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ public sealed class StagingGate
|
|||||||
_clock = clock ?? TimeProvider.System;
|
_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>
|
/// <summary>Verwirft offene Vorschlaege, die aelter als die Verfallsfrist sind. Gibt die Anzahl zurueck.</summary>
|
||||||
public Task<int> ExpireAsync(CancellationToken ct)
|
public Task<int> ExpireAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -115,7 +121,7 @@ public sealed class StagingGate
|
|||||||
vorhanden.Id);
|
vorhanden.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
var id = await _repo.AppendAsync(new StagedCall
|
var staged = new StagedCall
|
||||||
{
|
{
|
||||||
RunId = runId,
|
RunId = runId,
|
||||||
AgentId = agentId,
|
AgentId = agentId,
|
||||||
@@ -124,13 +130,25 @@ public sealed class StagingGate
|
|||||||
Action = action,
|
Action = action,
|
||||||
ArgumentsJson = argumentsJson,
|
ArgumentsJson = argumentsJson,
|
||||||
Proposal = BuildProposal(tool, action, 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(
|
return new StagingInterception(
|
||||||
StagingOutcome.Staged,
|
StagingOutcome.Staged,
|
||||||
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. {einstufung.Grund} " +
|
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. {einstufung.Grund} " +
|
||||||
"Die Aktion wird erst nach menschlicher Freigabe ausgeführt. Scheitert sie oder wird sie " +
|
(beiLeitung is null
|
||||||
"abgelehnt, wirst du geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
|
? "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);
|
id);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -73,6 +73,21 @@ public sealed record StagedCall
|
|||||||
|
|
||||||
/// <summary>Bei Ablehnung: der Grund.</summary>
|
/// <summary>Bei Ablehnung: der Grund.</summary>
|
||||||
public string? RejectionReason { get; init; }
|
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
|
public interface IStagingRepository
|
||||||
@@ -116,4 +131,10 @@ public interface IStagingRepository
|
|||||||
|
|
||||||
/// <summary>#660: Everything a run proposed for approval - for the run detail view.</summary>
|
/// <summary>#660: Everything a run proposed for approval - for the run detail view.</summary>
|
||||||
Task<IReadOnlyList<StagedCall>> ListForRunAsync(string runId, CancellationToken ct);
|
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.
|
// ausgeführt wird.
|
||||||
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, decidedBy, null, DateTime.UtcNow, ct))
|
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, decidedBy, null, DateTime.UtcNow, ct))
|
||||||
return StagingResult.AlreadyDecided;
|
return StagingResult.AlreadyDecided;
|
||||||
|
await CloseLeadTaskAsync(call, decidedBy, ct);
|
||||||
|
|
||||||
string result;
|
string result;
|
||||||
StagingStatus final;
|
StagingStatus final;
|
||||||
@@ -202,6 +203,7 @@ public sealed class StagingService
|
|||||||
|
|
||||||
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, decidedBy, reason, DateTime.UtcNow, ct))
|
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, decidedBy, reason, DateTime.UtcNow, ct))
|
||||||
return StagingResult.AlreadyDecided;
|
return StagingResult.AlreadyDecided;
|
||||||
|
await CloseLeadTaskAsync(call, decidedBy, ct);
|
||||||
|
|
||||||
await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct);
|
await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct);
|
||||||
await SammelnAsync(call, "abgelehnt", $"Grund: {reason}", weckt: true, ct);
|
await SammelnAsync(call, "abgelehnt", $"Grund: {reason}", weckt: true, ct);
|
||||||
@@ -211,6 +213,30 @@ public sealed class StagingService
|
|||||||
|
|
||||||
// ─── Hilfsfunktionen ───
|
// ─── 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>
|
/// <summary>
|
||||||
/// Trägt eine Entscheidung in die offene Sammelaufgabe des Agenten ein und schiebt ihren
|
/// 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
|
/// 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 =
|
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 "
|
+ "vorgelegt hattest. Freigegebene Aktionen wurden genau so ausgeführt, wie du sie vorgelegt "
|
||||||
+ "hast, abgelehnte gar nicht.\n"
|
+ "hast, abgelehnte gar nicht.\n"
|
||||||
+ "- Gescheitert: Prüfe die Ursache. Liegt sie bei dir (falscher Name, falsche Spalte, "
|
+ "- 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", "Runtime", "TEXT NOT NULL DEFAULT ''");
|
||||||
SpalteErgaenzen(connection, "RunUsage", "OnSubscription", "INTEGER NOT NULL DEFAULT 0");
|
SpalteErgaenzen(connection, "RunUsage", "OnSubscription", "INTEGER NOT NULL DEFAULT 0");
|
||||||
SpalteErgaenzen(connection, "RunReceipts", "BudgetPot", "TEXT NULL");
|
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.
|
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
|
||||||
using var index = connection.CreateCommand();
|
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>
|
/// <summary>#689: The desk for escalations, conflicts and decisions. <c>null</c> outside the engine.</summary>
|
||||||
public Cases.ICaseDesk? Cases { get; init; }
|
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>
|
/// <summary>#654: Where code runs — never in the main process. Unavailable under Windows.</summary>
|
||||||
public Sandbox.ISandboxRunner? Sandbox { get; init; }
|
public Sandbox.ISandboxRunner? Sandbox { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -573,6 +573,16 @@ button.klein {
|
|||||||
background: var(--surface-hover);
|
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) ─── */
|
/* ─── Datenlisten (dl/dt/dd) ─── */
|
||||||
|
|
||||||
.datenliste {
|
.datenliste {
|
||||||
|
|||||||
@@ -95,6 +95,11 @@
|
|||||||
"org.ceiling": "Rechte-Obergrenze",
|
"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.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.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.ceilingInherited": "Von oben begrenzt auf: {tools}",
|
||||||
"org.settings": "Sammelwerte",
|
"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.",
|
"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.rejectButton": "Aufruf ablehnen",
|
||||||
"staging.rejected": "Aufruf wurde abgelehnt.",
|
"staging.rejected": "Aufruf wurde abgelehnt.",
|
||||||
"staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}",
|
"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.title": "Agenten-Kommunikation (A2A)",
|
||||||
"agentChats.allAgents": "Alle Agenten",
|
"agentChats.allAgents": "Alle Agenten",
|
||||||
|
|||||||
@@ -95,6 +95,11 @@
|
|||||||
"org.ceiling": "Tool ceiling",
|
"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.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.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.ceilingInherited": "Limited from above to: {tools}",
|
||||||
"org.settings": "Unit-wide values",
|
"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.",
|
"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.rejectButton": "Reject Call",
|
||||||
"staging.rejected": "Call was rejected.",
|
"staging.rejected": "Call was rejected.",
|
||||||
"staging.rejectFailed": "Rejection failed: {error}",
|
"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.title": "Agent Communication (A2A)",
|
||||||
"agentChats.allAgents": "All Agents",
|
"agentChats.allAgents": "All Agents",
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ function unitPanel(unit) {
|
|||||||
|
|
||||||
cards.push(unitWorkspaceCard(unit));
|
cards.push(unitWorkspaceCard(unit));
|
||||||
cards.push(ceilingCard(unit));
|
cards.push(ceilingCard(unit));
|
||||||
|
cards.push(approvalsCard(unit));
|
||||||
cards.push(settingsCard(unit));
|
cards.push(settingsCard(unit));
|
||||||
|
|
||||||
// Members.
|
// 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) {
|
function settingsCard(unit) {
|
||||||
const setting = erstelleElement("select", {},
|
const setting = erstelleElement("select", {},
|
||||||
...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) })));
|
...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) })));
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ import {
|
|||||||
|
|
||||||
let wartendeAufrufe = [];
|
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() {
|
export async function holeWartendeAnzahl() {
|
||||||
try {
|
try {
|
||||||
const liste = await api("/staging");
|
const liste = await api("/staging");
|
||||||
return Array.isArray(liste) ? liste.length : 0;
|
return Array.isArray(liste) ? liste.filter(menschWartet).length : 0;
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -48,7 +53,8 @@ export async function rendereStaging() {
|
|||||||
|
|
||||||
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
|
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
|
||||||
const dauerBereich = erstelleElement("div", { id: "staging-dauer-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);
|
container.append(layout);
|
||||||
|
|
||||||
await ladeFreigaben();
|
await ladeFreigaben();
|
||||||
@@ -67,8 +73,9 @@ async function ladeFreigaben() {
|
|||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
|
|
||||||
// Aktualisiere Badge in Sidebar
|
// Aktualisiere Badge in Sidebar
|
||||||
aktualisiereStagingBadge(wartendeAufrufe.length);
|
aktualisiereStagingBadge(wartendeAufrufe.filter(menschWartet).length);
|
||||||
ladeDauerfreigaben();
|
ladeDauerfreigaben();
|
||||||
|
ladeLeitungen();
|
||||||
|
|
||||||
if (!wartendeAufrufe || wartendeAufrufe.length === 0) {
|
if (!wartendeAufrufe || wartendeAufrufe.length === 0) {
|
||||||
container.append(
|
container.append(
|
||||||
@@ -137,6 +144,14 @@ async function ladeFreigaben() {
|
|||||||
|
|
||||||
const teile = [datenliste, argBox];
|
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) {
|
if (anfrage.suggestAllowSimilar) {
|
||||||
teile.push(
|
teile.push(
|
||||||
erstelleElement("div", {
|
erstelleElement("div", {
|
||||||
@@ -169,7 +184,8 @@ async function ladeFreigaben() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const karte = erstelleKarte(`${anfrage.tool}.${anfrage.action || "?"}`, teile, {
|
const karte = erstelleKarte(`${anfrage.tool}.${anfrage.action || "?"}`, teile, {
|
||||||
aktionen: kopfAktionen
|
aktionen: kopfAktionen,
|
||||||
|
klasse: anfrage.withLead ? "staging-bei-leitung" : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
container.append(karte);
|
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.
|
* #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.
|
- state_position: answer a request for your position or a statement.
|
||||||
After the deadline a human decides.
|
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.
|
- 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("""
|
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||||
@@ -39,7 +40,7 @@ public sealed class EscalationTool : IAgentTool
|
|||||||
"properties": {
|
"properties": {
|
||||||
"action": {
|
"action": {
|
||||||
"type": "string",
|
"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." },
|
"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." },
|
"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." },
|
"note": { "type": "string", "description": "handle: what you did about it." },
|
||||||
"assessment": { "type": "string", "description": "forward: your view, added to the case." },
|
"assessment": { "type": "string", "description": "forward: your view, added to the case." },
|
||||||
"with": { "type": "string", "description": "open_conflict: the agent you disagree with." },
|
"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"]
|
"required": ["action"]
|
||||||
}
|
}
|
||||||
@@ -70,6 +73,10 @@ public sealed class EscalationTool : IAgentTool
|
|||||||
if (Get(input, "action") == "request_budget")
|
if (Get(input, "action") == "request_budget")
|
||||||
return await SetUpBudgetAsync(input, context, ct);
|
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)
|
if (context.Cases is not { } desk)
|
||||||
return ToolResult.Fail("Escalation is not available in this instance.");
|
return ToolResult.Fail("Escalation is not available in this instance.");
|
||||||
|
|
||||||
@@ -138,7 +145,7 @@ public sealed class EscalationTool : IAgentTool
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
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);
|
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.");
|
"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)
|
private static string Describe(Decision d)
|
||||||
=> CaseDesk.DecisionLine(d) + (string.IsNullOrWhiteSpace(d.Reasoning) ? "" : $"\n Reasoning: {d.Reasoning}");
|
=> CaseDesk.DecisionLine(d) + (string.IsNullOrWhiteSpace(d.Reasoning) ? "" : $"\n Reasoning: {d.Reasoning}");
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,13 @@ public sealed record StagedCallDto(
|
|||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
int PreviousApprovals = 0,
|
int PreviousApprovals = 0,
|
||||||
bool CanAllowSimilar = false,
|
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>
|
/// <summary>#641: Eine Dauerfreigabe - "Gleichartiges kuenftig erlauben".</summary>
|
||||||
public sealed record StandingApprovalDto(
|
public sealed record StandingApprovalDto(
|
||||||
|
|||||||
@@ -220,7 +220,10 @@ internal static class Mapping
|
|||||||
call.CreatedAt,
|
call.CreatedAt,
|
||||||
bisherFreigegeben,
|
bisherFreigegeben,
|
||||||
erlaubbar,
|
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)
|
public static StandingApprovalDto ToDto(this Dauerfreigabe d)
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ public sealed record UnitDto(
|
|||||||
IReadOnlyList<string>? EffectiveToolCeiling,
|
IReadOnlyList<string>? EffectiveToolCeiling,
|
||||||
UnitDefaultsDto Defaults,
|
UnitDefaultsDto Defaults,
|
||||||
string Path,
|
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);
|
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));
|
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) =>
|
staging.MapGet("/standing", async (AppHost host, CancellationToken ct, bool revoked = false) =>
|
||||||
{
|
{
|
||||||
if (host.Staging is not { } service)
|
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,
|
u.Id, u.Name, u.ParentId, u.LeaderAgentId, u.Goal, u.ToolCeiling,
|
||||||
chart.ToolCeiling(u.Id)?.Order(StringComparer.OrdinalIgnoreCase).ToList(),
|
chart.ToolCeiling(u.Id)?.Order(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||||
new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted),
|
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();
|
.ToList();
|
||||||
|
|
||||||
var agents = host.Instance.Agents
|
var agents = host.Instance.Agents
|
||||||
@@ -80,6 +81,10 @@ internal static class OrganisationEndpoints
|
|||||||
? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target)))
|
? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target)))
|
||||||
: host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse());
|
: 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) =>
|
org.MapPut("/units/{unitId}/ceiling", (AppHost host, string unitId, CeilingInputDto input) =>
|
||||||
input.Preview
|
input.Preview
|
||||||
? Results.Ok(ToDto(host.Organisation.PreviewToolCeiling(unitId, input.Tools)))
|
? Results.Ok(ToDto(host.Organisation.PreviewToolCeiling(unitId, input.Tools)))
|
||||||
|
|||||||
@@ -251,6 +251,43 @@ public sealed class OrgAdministrationTests : IDisposable
|
|||||||
Find("newbie").UnitId.ShouldBe("amazon");
|
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) ═══════════════════
|
// ═══════════════════ the top (#690) ═══════════════════
|
||||||
|
|
||||||
private void MakeDecider(string id)
|
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>
|
/// <summary>Verschiebt den Anlagezeitpunkt eines Vorschlags in die Vergangenheit - fuer Verfallstests.</summary>
|
||||||
public void Altern(long id, TimeSpan um)
|
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