Grossaufgaben und Sonderbudget (#717, Stufe 2+3)
Eine Aufgabe, deren eigene Schaetzung (gemessen, Geschwister oder Autor) ueber der Haelfte der Tageskostengrenze liegt, bekommt zuerst einen Planungslauf: teilen (Teile unter der Elternaufgabe) oder Sonderbudget beantragen. Danach wartet sie auf ihre Teile bzw. die Freigabe und laeuft einmal zum Zusammenfuehren. Sonderbudget ueber Escalation.request_budget, ausnahmslos freigabepflichtig (auch gegen Werkzeugkonfiguration und Dauerfreigabe). Laeufe im Baum ziehen den Topf statt der Tagesgrenzen, tragen BudgetPot in Verbrauch und Beleg und sind in der Kostenanalyse (nach Budget) und auf der Aufgabenkarte gesondert sichtbar. Aufgebraucht zaehlen sie wieder gegen den Tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
183e922278
commit
67649559f5
@@ -1,6 +1,9 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
using ClawdDotNet.Core.Budget;
|
||||
using ClawdDotNet.Core.Cases;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
|
||||
namespace ClawdDotNet.Tools.Taskboard;
|
||||
@@ -27,6 +30,7 @@ public sealed class EscalationTool : IAgentTool
|
||||
- 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.
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
@@ -35,17 +39,18 @@ public sealed class EscalationTool : IAgentTool
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position"]
|
||||
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget"]
|
||||
},
|
||||
"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." },
|
||||
"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." },
|
||||
"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." },
|
||||
@@ -55,8 +60,16 @@ public sealed class EscalationTool : IAgentTool
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
/// <summary>#717: A request that cannot be granted is refused before it reaches a human.</summary>
|
||||
public string? Preflight(JsonElement input, AgentToolContext context)
|
||||
=> Get(input, "action") == "request_budget" ? CheckBudgetRequest(input, context, out _, out _) : null;
|
||||
|
||||
public async Task<ToolResult> 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);
|
||||
|
||||
if (context.Cases is not { } desk)
|
||||
return ToolResult.Fail("Escalation is not available in this instance.");
|
||||
|
||||
@@ -125,12 +138,64 @@ 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.");
|
||||
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget.");
|
||||
}
|
||||
|
||||
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
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<ToolResult> 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 string Describe(Decision d)
|
||||
=> CaseDesk.DecisionLine(d) + (string.IsNullOrWhiteSpace(d.Reasoning) ? "" : $"\n Reasoning: {d.Reasoning}");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user