using System.Text; using System.Text.Json; using ClawdDotNet.Core.Cases; using ClawdDotNet.Core.Tools; namespace ClawdDotNet.Tools.Taskboard; /// /// #689: Escalate, decide, disagree — the agent's side of . Every agent /// has it (): being able to say "I cannot get on" is /// part of working in an organisation, not a permission. /// /// 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. /// 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 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; }