Eskalation, Konflikt und Beschluss als eigene Vorgaenge (#689, Stufe 1+2)

- Cases/Decisions in der Instanz-DB; CaseDesk fuehrt die Regeln, der Brett-Task traegt
  den Vorgang zum jeweiligen Halter
- Eskalation nur nach oben, eine Ebene je Schritt; die Leitung entscheidet (mit Begruendung,
  bindet ihren Bereich), erledigt selbst oder reicht mit Einschaetzung weiter
- Erst nachsehen: bindende Beschluesse werden vor dem Eskalieren gezeigt; dieselbe Frage
  aus derselben Einheit schliesst sich dem offenen Vorgang an
- Tagesdeckel je Einheit; nach der Frist wird ein Mensch gerufen (Anschluss fuer #644)
- Konflikt: zuerst die Gegenseite, dann Zustellung an die niedrigste gemeinsame Leitung;
  Ebenen dazwischen vermerkt, nicht befragt
- Fremdinhalt-Stufe (#715): Eskalation ohne eigenen Text, kein Konflikt mit Position
- Jede Ebene haengt an (#697), beim Halter gerahmt als Daten
- Escalation ist ein implizites Werkzeug jedes Agenten; Briefing nennt den Weg
- Scanner behaelt einen im Lauf gesetzten Status blockiert/abgebrochen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 09:46:20 +02:00
co-authored by Claude Opus 5.5
parent c03664cf29
commit 36fe1ad2aa
19 changed files with 1878 additions and 7 deletions
@@ -0,0 +1,157 @@
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Cases;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Tools.Taskboard;
/// <summary>
/// #689: Escalate, decide, disagree — the agent's side of <see cref="ICaseDesk"/>. Every agent
/// has it (<see cref="ToolRegistry.ImplicitTools"/>): being able to say "I cannot get on" is
/// part of working in an organisation, not a permission.
///
/// <para>The rules are the desk's, not the tool's: who the next holder is, who decides a
/// conflict, what a decision binds. The tool only reads the arguments and passes on what the
/// engine knows — the caller, its tier, its run.</para>
/// </summary>
public sealed class EscalationTool : IAgentTool
{
public string Name => CaseDesk.ToolName;
public string Description => """
Escalate what you cannot decide, and decide what is escalated to you.
- find_decisions: decisions that bind you and answer a question. escalate shows them anyway.
- escalate: you cannot get on and it is not yours to decide. Goes to your lead (or a human). Name the task you are stuck on; it waits for the answer.
- list_cases: the open cases you hold, raised, are a side of, or are told of.
As the holder of a case: decide (answer + reasoning; binds your area), handle (you took care of it), or forward (to your own lead, with your assessment). Never send a case back down unresolved.
- open_conflict: you disagree with an agent you cannot instruct and that cannot instruct you. It goes to the lowest place above both of you, after the other side stated its position.
- state_position: answer a request for your position or a statement.
After the deadline a human decides.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position"]
},
"subject": { "type": "string", "description": "What it is about, in a few words — like a title. escalate, open_conflict, find_decisions." },
"question": { "type": "string", "description": "escalate: the question that has to be decided." },
"tried": { "type": "string", "description": "escalate: what you tried, and what you found (or did not find)." },
"needed": { "type": "string", "description": "escalate: what you would need to get on." },
"task": { "type": "string", "description": "escalate, open_conflict: the task you are stuck on. It waits for the answer." },
"force": { "type": "boolean", "description": "escalate: although a decision seems to answer it — say in 'tried' why it does not fit." },
"case": { "type": "string", "description": "The case id (c-…) for decide, handle, forward, state_position." },
"answer": { "type": "string", "description": "decide: the decision." },
"reasoning": { "type": "string", "description": "decide: why. Required — a decision without reasons cannot be applied." },
"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." }
},
"required": ["action"]
}
""").RootElement.Clone();
public async Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (context.Cases is not { } desk)
return ToolResult.Fail("Escalation is not available in this instance.");
var me = context.AgentId;
var run = context.RunId;
var caseId = Get(input, "case") ?? "";
CaseOutcome outcome;
switch (Get(input, "action"))
{
case "find_decisions":
{
var subject = Get(input, "subject") ?? Get(input, "question");
if (string.IsNullOrWhiteSpace(subject))
return ToolResult.Fail("'subject' is required — what should be looked for?");
var found = await desk.FindDecisionsAsync(me, subject, Get(input, "question"), ct);
return ToolResult.Ok(found.Count == 0
? $"No decision that binds you answers \"{subject.Trim()}\"."
: "Decisions that bind you:\n" + string.Join("\n", found.Take(10).Select(Describe)));
}
case "escalate":
outcome = await desk.EscalateAsync(new EscalationRequest(
me, context.Trust, Get(input, "subject") ?? "", Get(input, "question") ?? "",
Get(input, "tried") ?? "", Get(input, "needed") ?? "",
Get(input, "task") ?? context.RunTaskId, run, GetBool(input, "force")), ct);
break;
case "list_cases":
{
var cases = await desk.CasesConcerningAsync(me, ct);
if (cases.Count == 0)
return ToolResult.Ok("No open case concerns you.");
var sb = new StringBuilder($"{cases.Count} open case(s):\n");
foreach (var c in cases)
sb.AppendLine($"- {c.Id} [{c.Kind.ToString().ToLowerInvariant()}] {c.Subject} — {Role(c, me)}; " +
$"with {(c.HeldByHuman ? "a human" : c.Holder)}; deadline {c.DeadlineUtc:yyyy-MM-dd HH:mm} UTC");
return ToolResult.Ok(sb.ToString().TrimEnd());
}
case "decide":
outcome = await desk.DecideAsync(caseId, me, Get(input, "answer") ?? "", Get(input, "reasoning") ?? "", run, ct);
break;
case "handle":
outcome = await desk.HandleAsync(caseId, me, Get(input, "note") ?? "", run, ct);
break;
case "forward":
outcome = await desk.ForwardAsync(caseId, me, context.Trust, Get(input, "assessment") ?? "", run, ct);
break;
case "open_conflict":
{
var other = (Get(input, "with") ?? "").Trim().TrimStart('@');
if (other.Length == 0)
return ToolResult.Fail("'with' is required — who do you disagree with?");
outcome = await desk.OpenConflictAsync(new ConflictRequest(
me, context.Trust, other, Get(input, "subject") ?? "", Get(input, "position") ?? "",
Get(input, "task") ?? context.RunTaskId, run), ct);
break;
}
case "state_position":
outcome = await desk.StatePositionAsync(caseId, me, context.Trust, Get(input, "position") ?? "", run, ct);
break;
default:
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position.");
}
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
}
private static string Describe(Decision d)
=> CaseDesk.DecisionLine(d) + (string.IsNullOrWhiteSpace(d.Reasoning) ? "" : $"\n Reasoning: {d.Reasoning}");
private static string Role(CaseRecord c, string me)
{
if (string.Equals(c.Holder, me, StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(c.PendingStatements.GetValueOrDefault(me)))
return c.PendingStatements.Count > 0 ? "you will decide it once the sides are heard" : "you hold it";
if (c.PendingStatements.Keys.Any(k => string.Equals(k, me, StringComparison.OrdinalIgnoreCase)))
return "your position is asked for";
if (c.Parties.Any(p => string.Equals(p, me, StringComparison.OrdinalIgnoreCase)))
return "you are a side";
if (c.Informed.Any(p => string.Equals(p, me, StringComparison.OrdinalIgnoreCase)))
return "it is decided in your area — you are told, not asked";
return "you raised it";
}
private static string? Get(JsonElement input, string name)
=> input.ValueKind == JsonValueKind.Object && input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
private static bool GetBool(JsonElement input, string name)
=> input.ValueKind == JsonValueKind.Object && input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
}