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:
Richard
2026-09-30 20:12:18 +02:00
co-authored by Claude Opus 5.5
parent dafb8241a0
commit f51186269c
27 changed files with 948 additions and 20 deletions
@@ -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}");