using System.Text; using System.Text.Json; using System.Globalization; using ClawdDotNet.Core.Budget; using ClawdDotNet.Core.Cases; using ClawdDotNet.Core.Inspection; using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Staging; using ClawdDotNet.Core.Tasks; 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. - 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. - doctrine_read: the doctrine of your unit — what holds there now. As a lead: doctrine_set (text, reason) sets your unit's doctrine — short, changed seldom; empty text removes it. Every version is kept. 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. As a lead, look at how the agents you lead work — never by asking them, always against what the engine recorded: spot_checks lists the runs picked for you, with what the audit log shows; inspect_run (run) shows one run in full; check_done (check, verdict ok|finding, note) closes a check. propose_measure (agent, measure, reasoning, check) puts a measure to a human: sharpen_task, change_model, adjust_rights, suspend — or security, if you suspect the agent is steered from outside. Routine you do the same way again and again may not need a model: automation_find (is there a proposal for it, and why was one rejected?), then automation_propose (purpose, trigger, tasks it replaces, services, risks, workflow as n8n JSON, test_guide; justification if it runs code). A human decides and imports it — you never create workflows yourself. As a voter: list_ballots shows the calls you are asked to vote on; cast_vote (vote, choice yes|no, reasoning, evidence) — check against your own data, with source and time. You never see the other votes. """; 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", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure", "list_ballots", "cast_vote", "automation_find", "automation_propose"] }, "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." }, "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. request_budget: the task the budget is for (required)." }, "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. request_budget: why this amount, and why it cannot be split." }, "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." }, "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. doctrine_set: why the doctrine changes." }, "text": { "type": "string", "description": "doctrine_set: what holds in your unit now. At most 2000 characters; empty removes it." }, "run": { "type": "string", "description": "inspect_run: the run id." }, "check": { "type": "integer", "description": "check_done, propose_measure: the spot check number." }, "verdict": { "type": "string", "enum": ["ok", "finding"], "description": "check_done: ok, or finding (then 'note' with the evidence)." }, "agent": { "type": "string", "description": "propose_measure: the agent it concerns." }, "measure": { "type": "string", "enum": ["sharpen_task", "change_model", "adjust_rights", "suspend", "security"], "description": "propose_measure: what should follow." }, "vote": { "type": "integer", "description": "cast_vote: the vote number (#…)." }, "choice": { "type": "string", "enum": ["yes", "no"], "description": "cast_vote: your vote." }, "evidence": { "type": "string", "description": "cast_vote: what you checked — source and time. Required: without it a vote is an opinion." }, "purpose": { "type": "string", "description": "automation_propose: what the workflow is for. automation_find: what to look for." }, "trigger": { "type": "string", "description": "automation_propose: what starts it (mail, schedule, webhook …) and how often it happens." }, "tasks": { "type": "array", "items": { "type": "string" }, "description": "automation_propose: the task ids it replaces — the saving is measured from their runs." }, "services": { "type": "array", "items": { "type": "string" }, "description": "automation_propose: the services and credentials it needs — names only, never values." }, "risks": { "type": "string", "description": "automation_propose: what can go wrong, and what it sends outside." }, "workflow": { "description": "automation_propose: the n8n workflow as exported JSON ({ nodes, connections }) — object or string. Credentials only as references." }, "test_guide": { "type": "string", "description": "automation_propose: how the human checks it after importing." }, "justification": { "type": "string", "description": "automation_propose: why a Code, Execute Command or SSH node is needed. Without it such nodes are refused." }, "answers": { "type": "integer", "description": "automation_propose: the rejected proposal (#) this one answers." }, "argument": { "type": "string", "description": "automation_propose: what is new against that rejection's reason." } }, "required": ["action"] } """).RootElement.Clone(); /// /// #698: A lead that reads content from outside may still set its unit's doctrine — but not /// unasked: the doctrine stands in every prompt of every member, the most convenient way to /// slip something to a whole unit from outside. A human approves it first. /// public StagingDecision? Vorgabe(string operation, AgentToolContext context) => operation == "doctrine_set" && context.Trust.ReadsForeignContent ? StagingDecision.Approve : null; /// #717: A request that cannot be granted is refused before it reaches a human. public string? Preflight(JsonElement input, AgentToolContext context) => Get(input, "action") == "request_budget" ? CheckBudgetRequest(input, context, out _, out _) : null; public async Task ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { // #717: Runs only after a human approved it — the staging rule for it cannot be lifted. if (Get(input, "action") == "request_budget") return await SetUpBudgetAsync(input, context, ct); // #698: The unit's doctrine. if (Get(input, "action") is "doctrine_read" or "doctrine_set") return await DoctrineAsync(input, context, ct); // #693: A lead looking at the runs of the agents it leads. if (Get(input, "action") is "spot_checks" or "inspect_run" or "check_done" or "propose_measure") return await SpotCheckAsync(input, context, ct); // #726: Proposing a workflow instead of doing routine by model. if (Get(input, "action") is "automation_find" or "automation_propose") return await AutomationAsync(input, context, ct); // #702: A voter's ballot. if (Get(input, "action") is "list_ballots" or "cast_vote") return await BallotAsync(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."); 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, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure, list_ballots, cast_vote, automation_find, automation_propose."); } return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message); } /// /// #717: What a budget request needs — checked twice: before it is put to a human, and when /// the approved call runs. The task must exist, and it must be the caller's: its assignee /// or its author. Nobody asks for money for someone else's work. /// private static string? CheckBudgetRequest(JsonElement input, AgentToolContext context, out string taskId, out decimal amount) { taskId = (Get(input, "task") ?? "").Trim(); amount = 0; if (context.SpecialBudgets is null || context.Tasks is null) return "Special budgets are not available in this instance."; if (taskId.Length == 0) return "'task' is required — the task the budget is for."; if (!input.TryGetProperty("amount_usd", out var a) || a.ValueKind != JsonValueKind.Number || !a.TryGetDecimal(out amount) || amount <= 0) return "'amount_usd' is required and must be above 0."; if (string.IsNullOrWhiteSpace(Get(input, "reasoning"))) return "'reasoning' is required — why this amount, and why the task cannot be split."; var task = context.Tasks.GetAsync(taskId, CancellationToken.None).GetAwaiter().GetResult(); if (task is null) return $"No task '{taskId}'."; var me = context.AgentId; if (!string.Equals(TaskAssignee.AgentId(task.Assignee), me, StringComparison.OrdinalIgnoreCase) && !string.Equals(task.CreatedBy, me, StringComparison.OrdinalIgnoreCase)) return $"Task '{taskId}' is neither assigned to you nor created by you — ask for a budget for your own work."; return null; } private static async Task SetUpBudgetAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { if (CheckBudgetRequest(input, context, out var taskId, out var amount) is { } problem) return ToolResult.Fail(problem); await context.SpecialBudgets!.SetAsync(new SpecialBudget { TaskId = taskId, AmountUsd = amount, Reason = Get(input, "reasoning")!.Trim(), RequestedBy = context.AgentId, ApprovedAt = DateTime.UtcNow }, ct); // A large task that waited for this decision is taken up again. if (await context.Tasks!.GetAsync(taskId, ct) is { Status: TaskItemStatus.Blocked, LargePlannedAt: not null }) await context.Tasks.SetStatusAsync(taskId, TaskItemStatus.Todo, DateTime.UtcNow, ct); return ToolResult.Ok( $"Special budget of {amount.ToString("0.00", CultureInfo.InvariantCulture)} USD for {taskId} and its parts is approved. " + "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 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 async Task SpotCheckAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { if (context.SpotChecks is not { } checks) return ToolResult.Fail("Spot checks are not available in this instance."); var me = context.AgentId; long? checkId = input.TryGetProperty("check", out var c) && c.ValueKind == JsonValueKind.Number && c.TryGetInt64(out var n) ? n : null; switch (Get(input, "action")) { case "spot_checks": { var pending = await checks.PendingForAsync(me, ct); var measures = (await checks.MeasuresByAsync(me, 14, ct)).Take(5).ToList(); var sb = new StringBuilder(); if (pending.Count == 0) sb.AppendLine("No spot check waits for you."); else { sb.AppendLine($"{pending.Count} run(s) to check — the findings come from the audit log, not from the agent:"); foreach (var check in pending) { sb.AppendLine($"- check {check.Id}: {check.AgentId}, run {check.RunId} ({check.Why}, {check.CreatedAt:yyyy-MM-dd HH:mm} UTC)"); if (check.Findings.Count == 0) sb.AppendLine(" nothing mechanical — judge whether the result answers the task (inspect_run)."); foreach (var f in check.Findings) sb.AppendLine($" {f.Kind}: {f.Text}"); } } if (measures.Count > 0) { sb.AppendLine("Your recent proposals:"); foreach (var m in measures) sb.AppendLine($"- {m.Id} {SpotChecks.Label(m.Kind)} for {m.AgentId}: {m.Status}" + (string.IsNullOrWhiteSpace(m.Note) ? "" : $" — {m.Note}")); } return ToolResult.Ok(sb.ToString().TrimEnd()); } case "inspect_run": { var runId = (Get(input, "run") ?? "").Trim(); if (runId.Length == 0) return ToolResult.Fail("'run' is required — the run id."); if (await checks.InspectAsync(runId, ct) is not { } run) return ToolResult.Fail($"No finished run '{runId}'."); if (!checks.Leads(me, run.Receipt.AgentId)) return ToolResult.Fail($"Run '{runId}' belongs to {run.Receipt.AgentId}, whom you do not lead."); return ToolResult.Ok(Describe(run)); } case "check_done": { if (checkId is not { } id) return ToolResult.Fail("'check' is required — the spot check number."); var verdict = Get(input, "verdict"); if (verdict is not ("ok" or "finding")) return ToolResult.Fail("'verdict' must be ok or finding."); var closed = await checks.CloseAsync(id, me, verdict == "finding", Get(input, "note") ?? "", ct); return closed.Ok ? ToolResult.Ok(closed.Message) : ToolResult.Fail(closed.Message); } default: { if (SpotChecks.ParseKind(Get(input, "measure")) is not { } kind) return ToolResult.Fail("'measure' must be one of sharpen_task, change_model, adjust_rights, suspend, security."); var proposed = await checks.ProposeAsync(me, Get(input, "agent") ?? "", kind, Get(input, "reasoning") ?? "", checkId, ct); return proposed.Ok ? ToolResult.Ok(proposed.Message) : ToolResult.Fail(proposed.Message); } } } private static async Task AutomationAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { if (context.Automations is not { } automations) return ToolResult.Fail("Automation proposals are not available in this instance."); if (Get(input, "action") == "automation_find") { var subject = Get(input, "purpose") ?? Get(input, "subject") ?? ""; if (string.IsNullOrWhiteSpace(subject)) return ToolResult.Fail("'purpose' is required — what to look for."); var found = await automations.FindAsync(subject, ct); if (found.Count == 0) return ToolResult.Ok($"No proposal is about \"{subject.Trim()}\"."); var sb = new StringBuilder("Proposals about it:\n"); foreach (var p in found) sb.AppendLine($"- #{p.Id} by {p.AgentId}: {p.Purpose} — {p.Status}" + (string.IsNullOrWhiteSpace(p.Reason) ? "" : $" (reason: {p.Reason})")); return ToolResult.Ok(sb.ToString().TrimEnd()); } var workflow = input.TryGetProperty("workflow", out var w) ? w.ValueKind == JsonValueKind.String ? w.GetString() ?? "" : w.GetRawText() : ""; long? answers = input.TryGetProperty("answers", out var a) && a.ValueKind == JsonValueKind.Number && a.TryGetInt64(out var n) ? n : null; var outcome = await automations.ProposeAsync(new Core.Automation.AutomationRequest( context.AgentId, Get(input, "purpose") ?? "", Get(input, "trigger") ?? "", List(input, "tasks"), List(input, "services"), Get(input, "risks") ?? "", Get(input, "test_guide") ?? "", workflow, Get(input, "justification"), answers, Get(input, "argument")), ct); return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message); } private static IReadOnlyList List(JsonElement input, string name) => input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Array ? v.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.String).Select(e => e.GetString()!.Trim()).Where(s => s.Length > 0).ToList() : []; private static async Task BallotAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { if (context.Votes is not { } votes) return ToolResult.Fail("Votes are not available in this instance."); if (Get(input, "action") == "list_ballots") { var open = await votes.BallotsForAsync(context.AgentId, ct); if (open.Count == 0) return ToolResult.Ok("No vote waits for you."); var sb = new StringBuilder($"{open.Count} vote(s) wait for you — check each against your own data:\n"); foreach (var (vote, call) in open) { sb.AppendLine($"- vote #{vote.Id}: {vote.Operation} from {vote.Proposer}, until {vote.Until:yyyy-MM-dd HH:mm} UTC. The call:"); sb.AppendLine(Core.Engine.UntrustedContent.Rahmen(call.Tool, string.IsNullOrWhiteSpace(call.ArgumentsJson) ? call.Proposal : call.ArgumentsJson)); } return ToolResult.Ok(sb.ToString().TrimEnd()); } if (!input.TryGetProperty("vote", out var v) || v.ValueKind != JsonValueKind.Number || !v.TryGetInt64(out var voteId)) return ToolResult.Fail("'vote' is required — the vote number."); var choice = Get(input, "choice"); if (choice is not ("yes" or "no")) return ToolResult.Fail("'choice' must be yes or no."); var cast = await votes.CastAsync(voteId, context.AgentId, context.Trust, choice == "yes", Get(input, "reasoning") ?? "", Get(input, "evidence") ?? "", context.RunId, ct); return cast.Ok ? ToolResult.Ok(cast.Message) : ToolResult.Fail(cast.Message); } /// One run as the engine recorded it. The result text is the agent's — marked as such. private static string Describe(RunInspection run) { var r = run.Receipt; var sb = new StringBuilder(); sb.AppendLine($"Run {r.RunId} of {r.AgentId} ({r.Model}), {r.OccurredAt:yyyy-MM-dd HH:mm} UTC: {r.Status}, {r.StepCount} step(s), " + (r.CostIsKnown ? $"{r.CostUsd.ToString("0.0000", CultureInfo.InvariantCulture)} USD" : "cost unknown")); if (run.Task is { } task) { sb.AppendLine($"Task {task.Id}: {task.Title}"); if (!string.IsNullOrWhiteSpace(task.Acceptance)) sb.AppendLine($"Done when: {task.Acceptance}"); } sb.AppendLine($"Calls recorded by the engine ({run.Calls.Count}):"); foreach (var call in run.Calls.Take(40)) sb.AppendLine($"- {call.At:HH:mm:ss} {call.Tool}{(call.Operation is { } op ? "." + op : "")}: {call.Status}" + (string.IsNullOrWhiteSpace(call.Summary) ? "" : $" — {Cap(call.Summary, 160)}")); if (run.Calls.Count > 40) sb.AppendLine($"- … and {run.Calls.Count - 40} more"); sb.AppendLine(run.Findings.Count == 0 ? "Mechanical findings: none." : "Mechanical findings:"); foreach (var f in run.Findings) sb.AppendLine($"- {f.Kind}: {f.Text}"); sb.AppendLine("Result as the agent wrote it (its claim, not evidence):"); sb.Append(string.IsNullOrWhiteSpace(run.Result) ? "(none)" : run.Result); return sb.ToString(); } private static string Cap(string text, int max) => text.Length <= max ? text : text[..max] + "…"; private static async Task DoctrineAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { if (context.Doctrines is not { } doctrines || context.Organisation is not { HasUnits: true } org) return ToolResult.Fail("Doctrines exist only in an instance with units."); if (Get(input, "action") == "doctrine_read") { var unit = org.LedBy(context.AgentId) ?? org.UnitOf(context.AgentId); if (unit is null) return ToolResult.Fail("You belong to no unit, so no doctrine applies to you."); return ToolResult.Ok(doctrines.Current(unit.Id) is { } holds ? $"Doctrine of \"{unit.Name}\" (version {holds.Version}, set by {holds.SetBy} on {holds.SetAt:yyyy-MM-dd}):\n{holds.Text}" : $"\"{unit.Name}\" has no doctrine."); } if (org.LedBy(context.AgentId) is not { } led) return ToolResult.Fail("Only the lead of a unit sets its doctrine. Raise what should hold with your lead."); var text = Get(input, "text") ?? ""; if (text.Trim().Length > SqliteDoctrineStore.MaxLength) return ToolResult.Fail($"A doctrine has at most {SqliteDoctrineStore.MaxLength} characters — it goes into every run of every member. Shorten it."); if (string.IsNullOrWhiteSpace(Get(input, "reason"))) return ToolResult.Fail("'reason' is required — why the doctrine changes. Every version is kept with it."); var set = await doctrines.SetAsync(led.Id, text, context.AgentId, context.RunId, Get(input, "reason")!.Trim(), ct); return ToolResult.Ok(set.IsEmpty ? $"The doctrine of \"{led.Name}\" is removed (version {set.Version})." : $"Doctrine of \"{led.Name}\" set (version {set.Version}). It reaches the members of your unit from their next run on — not the sub-units."); } 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; }