Abstimmung: mehrere Agenten pruefen unabhaengig und stimmen blind ab (#702)

A unit can put operations to a blind vote instead of its lead. Not opinions
are counted but independent checks enforced.

- UnitConfig.votes: operations, voters, approveAt, rejectAt, quorum, hours,
  perDay - set only by a human (OrgAdministration.SetVotes).
- Core/Staging/Votes: routes a covered staged call before the lead; one task
  per voter with the frozen call and nothing else; cast_vote needs reasoning
  and evidence. Counted as soon as the result is settled; in between and
  without the quorum a human decides. The proposer does not vote, voters that
  read foreign content get no ballot, changes to agents never go to a vote.
- Escalation tool: list_ballots, cast_vote.
- Approvals view shows each vote with every ballot and the voters' costs;
  organisation view sets the rule and lists the votes held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 12:04:33 +02:00
co-authored by Claude Opus 5.5
parent d834e5ac38
commit a5dd503d9b
23 changed files with 1238 additions and 11 deletions
@@ -41,6 +41,7 @@ public sealed class EscalationTool : IAgentTool
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.
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("""
@@ -49,7 +50,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", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure"]
"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"]
},
"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." },
@@ -72,7 +73,10 @@ public sealed class EscalationTool : IAgentTool
"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." }
"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." }
},
"required": ["action"]
}
@@ -104,6 +108,10 @@ public sealed class EscalationTool : IAgentTool
if (Get(input, "action") is "spot_checks" or "inspect_run" or "check_done" or "propose_measure")
return await SpotCheckAsync(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);
@@ -176,7 +184,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, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure.");
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.");
}
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
@@ -328,6 +336,35 @@ public sealed class EscalationTool : IAgentTool
}
}
private static async Task<ToolResult> 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);
}
/// <summary>One run as the engine recorded it. The result text is the agent's — marked as such.</summary>
private static string Describe(RunInspection run)
{