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;
}