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:
Richard
2026-09-25 15:47:41 +02:00
co-authored by Claude Opus 5.5
parent 183e922278
commit 67649559f5
30 changed files with 1123 additions and 55 deletions
+14 -2
View File
@@ -67,6 +67,9 @@ public sealed class AppHost : IAsyncDisposable
/// <summary>#689: The desk a human decides at; <c>null</c> without a task board.</summary>
public Core.Cases.ICaseDesk? CaseDesk => Engine?.CaseDesk;
/// <summary>#717: Special budgets — one pot per task tree, approved by a human.</summary>
public Core.Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
/// <summary>
/// Zustandsspeicher und Audit-Log der Instanz. Nicht oeffentlich — sie gehoeren dem
/// Aufbau, nicht den Oberflaechen; gebraucht werden sie hier fuer die Sichtung
@@ -548,6 +551,7 @@ public sealed class AppHost : IAsyncDisposable
var usage = new SqliteUsageRepository(storage);
var transcripts = new Core.Transcripts.SqliteRunTranscriptStore(storage);
var cases = new Core.Cases.SqliteCaseRepository(storage);
var specialBudgets = new SqliteSpecialBudgetRepository(storage);
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
var pricing = new ModelPricingCatalog();
@@ -565,7 +569,9 @@ public sealed class AppHost : IAsyncDisposable
TranscriptStore = transcripts,
DefaultTranscriptRetention = () => instance.PromptHistory.RetentionDays,
MaxDelegationDepth = instance.MaxDelegationDepth,
DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds)
DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds),
// #717: runs in a funded task tree draw on its pot, not on the daily limits.
SpecialBudgets = specialBudgets
};
engine.SetAgentConfigProvider(
@@ -607,6 +613,7 @@ public sealed class AppHost : IAsyncDisposable
Usage = usage,
Transcripts = transcripts,
Cases = cases,
SpecialBudgets = specialBudgets,
Insights = new RunInsights(audit, transcripts, board, staging,
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
? engine.TranscriptRetentionFor(agent)
@@ -809,7 +816,12 @@ public sealed class AppHost : IAsyncDisposable
var estimate = estimator is null ? TaskEstimate.Nothing : await estimator.EstimateAsync(task, ct);
await board.CreateAsync(FillWorkNotice.QuestionFor(task, estimate), ct);
},
fillWarnBefore: () => instance.FillWork.WarnBeforeDeadline);
fillWarnBefore: () => instance.FillWork.WarnBeforeDeadline,
// #717: A large task is not simply started — its run proposes a split or a special budget.
largeTaskCheck: estimator is null
? null
: new LargeTaskCheck(estimator, taskRepository, engine.SpecialBudgets, () => instance.Agents, () => instance.FillWork),
noteOnTask: async (taskId, note, ct) => await board.AddCommentAsync(taskId, "system", note, ct));
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
_ = Task.Run(async () =>
@@ -31,10 +31,10 @@ public sealed class SqliteUsageRepository : IUsageRepository
cmd.CommandText = """
INSERT INTO RunUsage
(AgentId, Model, PromptTokens, CompletionTokens, CachedTokens,
CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt, UsageDate)
CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt, UsageDate, BudgetPot)
VALUES
(@agentId, @model, @prompt, @completion, @cached,
@cost, @costKnown, @status, @steps, @duration, @occurredAt, @usageDate)
@cost, @costKnown, @status, @steps, @duration, @occurredAt, @usageDate, @pot)
""";
cmd.Parameters.AddWithValue("@agentId", usage.AgentId);
@@ -49,16 +49,24 @@ public sealed class SqliteUsageRepository : IUsageRepository
cmd.Parameters.AddWithValue("@duration", usage.DurationMs);
cmd.Parameters.AddWithValue("@occurredAt", occurredAt.ToString("O"));
cmd.Parameters.AddWithValue("@usageDate", FormatDate(DateOnly.FromDateTime(occurredAt)));
cmd.Parameters.AddWithValue("@pot", usage.BudgetPot ?? "");
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public async Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
public Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
=> GetDailyCoreAsync(date, agentId, withoutPots: false, ct);
public Task<DailyUsage> GetDailyForBudgetAsync(DateOnly date, string agentId, CancellationToken ct)
=> GetDailyCoreAsync(date, agentId, withoutPots: true, ct);
private async Task<DailyUsage> GetDailyCoreAsync(DateOnly date, string agentId, bool withoutPots, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
var filterAgent = !string.IsNullOrWhiteSpace(agentId);
var filterPots = withoutPots ? " AND BudgetPot = ''" : "";
cmd.CommandText = $"""
SELECT COALESCE(SUM(PromptTokens), 0),
@@ -66,7 +74,7 @@ public sealed class SqliteUsageRepository : IUsageRepository
COUNT(*),
COALESCE(SUM(CASE WHEN CostIsKnown = 0 THEN 1 ELSE 0 END), 0)
FROM RunUsage
WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")}
WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")}{filterPots}
""";
cmd.Parameters.AddWithValue("@date", FormatDate(date));
if (filterAgent)
@@ -88,7 +96,7 @@ public sealed class SqliteUsageRepository : IUsageRepository
using var costCmd = conn.CreateCommand();
costCmd.CommandText = $"""
SELECT CostUsd FROM RunUsage
WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")}
WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")}{filterPots}
""";
costCmd.Parameters.AddWithValue("@date", FormatDate(date));
if (filterAgent)
@@ -134,6 +142,9 @@ public sealed class SqliteUsageRepository : IUsageRepository
CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt
FROM RunUsage
WHERE AgentId = @agent COLLATE NOCASE
-- #717: a special budget's runs are the large kind; they would inflate the
-- estimate of the agent's ordinary run.
AND BudgetPot = ''
ORDER BY OccurredAt DESC
LIMIT @limit
""";
@@ -28,6 +28,12 @@ public sealed record RunUsage
public long DurationMs { get; init; }
public DateTime OccurredAt { get; init; }
/// <summary>
/// #717: The task whose special budget the run drew on; empty for the daily budget. Such
/// runs do not count against the daily limits — that is what the pot is for.
/// </summary>
public string BudgetPot { get; init; } = "";
}
/// <summary>Verbrauch eines Tages, aggregiert.</summary>
@@ -51,6 +57,13 @@ public interface IUsageRepository
/// <summary>Tagesverbrauch eines Agenten. <paramref name="agentId"/> leer = ganze Instanz.</summary>
Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct);
/// <summary>
/// #717: What counts against the daily limits — the day's usage without runs that drew on
/// a special budget. <see cref="GetDailyAsync"/> keeps showing everything that was used.
/// </summary>
Task<DailyUsage> GetDailyForBudgetAsync(DateOnly date, string agentId, CancellationToken ct)
=> GetDailyAsync(date, agentId, ct);
Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct);
/// <summary>
@@ -88,7 +88,7 @@ public sealed class UsageAnalytics(
}
/// <param name="bucket"><c>hour</c> or <c>day</c>.</param>
/// <param name="groupBy"><c>agent</c>, <c>model</c> or <c>source</c>.</param>
/// <param name="groupBy"><c>agent</c>, <c>model</c>, <c>source</c> or <c>budget</c> (#717: daily or a special budget).</param>
/// <param name="utcOffset">Offset of the viewer's local time - a "day" is the viewer's day.</param>
public async Task<IReadOnlyList<SeriesPoint>> SeriesAsync(
RunQuery q, string bucket, string groupBy, TimeSpan utcOffset, CancellationToken ct)
@@ -216,6 +216,8 @@ public sealed class UsageAnalytics(
{
"model" => string.IsNullOrWhiteSpace(r.Model) ? "(unknown)" : r.Model,
"source" => r.Source,
// #717: A special budget is visible apart — it is outside the daily limits.
"budget" => string.IsNullOrWhiteSpace(r.BudgetPot) ? "daily" : "special:" + r.BudgetPot,
_ => r.AgentId
};
@@ -127,6 +127,12 @@ public sealed record RunReceipt
/// chain. With it, a result can be followed back to the run that asked for it.
/// </summary>
public string? CallerRunId { get; init; }
/// <summary>
/// #717: The task whose special budget the run drew on; <c>null</c> for a run on the daily
/// budget. Keeps the pot visible apart in the cost analysis.
/// </summary>
public string? BudgetPot { get; init; }
}
/// <summary>
@@ -46,11 +46,11 @@ public sealed class SqliteAuditRepository : IAuditRepository
INSERT INTO RunReceipts
(RunId, AgentId, Model, Source, TaskId, Status, StepCount,
PromptTokens, CompletionTokens, CachedTokens, CostUsd, CostIsKnown,
DurationMs, ResultRef, OccurredAt, CallerRunId)
DurationMs, ResultRef, OccurredAt, CallerRunId, BudgetPot)
VALUES
(@runId, @agentId, @model, @source, @taskId, @status, @stepCount,
@prompt, @completion, @cached, @cost, @costKnown,
@durationMs, @resultRef, @occurredAt, @callerRunId)
@durationMs, @resultRef, @occurredAt, @callerRunId, @budgetPot)
""";
cmd.Parameters.AddWithValue("@runId", receipt.RunId);
cmd.Parameters.AddWithValue("@agentId", receipt.AgentId);
@@ -68,6 +68,7 @@ public sealed class SqliteAuditRepository : IAuditRepository
cmd.Parameters.AddWithValue("@resultRef", receipt.ResultRef);
cmd.Parameters.AddWithValue("@occurredAt", Format(receipt.OccurredAt));
cmd.Parameters.AddWithValue("@callerRunId", (object?)receipt.CallerRunId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@budgetPot", (object?)receipt.BudgetPot ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
@@ -264,7 +265,7 @@ public sealed class SqliteAuditRepository : IAuditRepository
private const string ReceiptColumns =
"Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " +
"CompletionTokens, CachedTokens, CostUsd, CostIsKnown, DurationMs, ResultRef, OccurredAt, CallerRunId";
"CompletionTokens, CachedTokens, CostUsd, CostIsKnown, DurationMs, ResultRef, OccurredAt, CallerRunId, BudgetPot";
private static async Task<IReadOnlyList<AuditEntry>> ReadEntriesAsync(SqliteCommand cmd, CancellationToken ct)
{
@@ -309,7 +310,8 @@ public sealed class SqliteAuditRepository : IAuditRepository
DurationMs = r.GetInt64(13),
ResultRef = r.GetString(14),
OccurredAt = Parse(r.GetString(15)),
CallerRunId = r.IsDBNull(16) ? null : r.GetString(16)
CallerRunId = r.IsDBNull(16) ? null : r.GetString(16),
BudgetPot = r.IsDBNull(17) ? null : r.GetString(17)
};
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
+50 -7
View File
@@ -54,6 +54,9 @@ public sealed class BudgetGuard
private readonly Dictionary<string, RunEstimate> _reservedByAgent = new(StringComparer.OrdinalIgnoreCase);
private RunEstimate _reservedInstance;
/// <summary>#717: What runs in flight have reserved of each special budget.</summary>
private readonly Dictionary<string, RunEstimate> _reservedByPot = new(StringComparer.OrdinalIgnoreCase);
public BudgetGuard(IUsageRepository usage, Func<DateTime>? now = null)
{
_usage = usage;
@@ -109,7 +112,7 @@ public sealed class BudgetGuard
_reservedInstance += estimate;
}
return (status, new Reservation(this, agent.AgentId, estimate));
return (status, new Reservation(() => Release(agent.AgentId, estimate)));
}
finally
{
@@ -117,9 +120,49 @@ public sealed class BudgetGuard
}
}
/// <summary>
/// #717: Reserves a run on a special budget instead of the daily limits. Refused once what
/// the pot's runs used, plus what runs in flight reserved, has reached the amount — the
/// same rule as for the daily limit: a run is not refused for its own estimate.
/// </summary>
public async Task<IDisposable?> ReservePotAsync(SpecialBudget pot, decimal spent, string agentId, CancellationToken ct)
{
var estimate = await EstimateRunAsync(agentId, ct);
await _reserveGate.WaitAsync(ct);
try
{
lock (_reservedLock)
{
var reserved = _reservedByPot.GetValueOrDefault(pot.TaskId);
if (spent + reserved.CostUsd >= pot.AmountUsd)
return null;
_reservedByPot[pot.TaskId] = reserved + estimate;
}
return new Reservation(() => ReleasePot(pot.TaskId, estimate));
}
finally
{
_reserveGate.Release();
}
}
private void ReleasePot(string potId, RunEstimate estimate)
{
lock (_reservedLock)
{
var left = _reservedByPot.GetValueOrDefault(potId) - estimate;
if (left.CostUsd <= 0 && left.Tokens <= 0)
_reservedByPot.Remove(potId);
else
_reservedByPot[potId] = left;
}
}
/// <summary>Today's usage of an agent, or with an empty id of the whole instance.</summary>
public Task<DailyUsage> GetUsedTodayAsync(string agentId, CancellationToken ct)
=> _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agentId, ct);
=> _usage.GetDailyForBudgetAsync(DateOnly.FromDateTime(_now()), agentId, ct);
/// <summary>What is currently reserved by runs in flight — for the agent, or with an
/// empty id for the whole instance.</summary>
@@ -159,14 +202,14 @@ public sealed class BudgetGuard
}
}
private sealed class Reservation(BudgetGuard guard, string agentId, RunEstimate estimate) : IDisposable
private sealed class Reservation(Action release) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
guard.Release(agentId, estimate);
release();
}
}
@@ -180,7 +223,7 @@ public sealed class BudgetGuard
var agentBudget = agent.Budget;
if (agentBudget.HasAnyLimit)
{
var used = await _usage.GetDailyAsync(today, agent.AgentId, ct);
var used = await _usage.GetDailyForBudgetAsync(today, agent.AgentId, ct);
if (Exceeds(agentBudget.DailyCostUsd, used.CostUsd + agentReserved.CostUsd))
{
@@ -203,7 +246,7 @@ public sealed class BudgetGuard
// Instanzgrenze — gilt für alle Agenten zusammen
if (instanceBudget.HasAnyLimit)
{
var used = await _usage.GetDailyAsync(today, agentId: "", ct);
var used = await _usage.GetDailyForBudgetAsync(today, agentId: "", ct);
if (Exceeds(instanceBudget.DailyCostUsd, used.CostUsd + instanceReserved.CostUsd))
{
@@ -232,7 +275,7 @@ public sealed class BudgetGuard
if (agent.Budget.DailyCostUsd <= 0)
return null;
var used = await _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agent.AgentId, ct);
var used = await _usage.GetDailyForBudgetAsync(DateOnly.FromDateTime(_now()), agent.AgentId, ct);
return Math.Max(0m, agent.Budget.DailyCostUsd - used.CostUsd);
}
@@ -0,0 +1,64 @@
using System.Globalization;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
/// <summary>
/// #717: A task whose estimate exceeds a large share of the agent's daily cost limit (default
/// half) is not simply started. Its run plans instead: split it into parts, or ask a human for
/// a special budget. Which way, the agent proposes; a special budget is always a human's call.
///
/// <para><b>Only on an estimate of this task.</b> Measured runs, its siblings' runs, or its
/// author's estimate. The agent's average run says nothing about one task — were it enough, an
/// agent with expensive runs would have every task stopped for planning.</para>
///
/// <para>Not for a task already split (it has parts), nor for one in a funded tree, nor for a
/// recurring one — its runs are the day's regular work and have their own limit.</para>
/// </summary>
public sealed class LargeTaskCheck(
TaskCostEstimator estimator,
ITaskRepository tasks,
ISpecialBudgetRepository? pots,
Func<IReadOnlyList<AgentConfig>> agents,
Func<FillWorkSettings> settings) : ILargeTaskCheck
{
public async Task<string?> PlanningInstructionAsync(TaskItem task, CancellationToken ct)
{
var agentId = TaskAssignee.AgentId(task.Assignee);
var agent = agents().FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase));
var share = settings().LargeTaskShare;
if (agent is not { Budget.DailyCostUsd: > 0 } || share <= 0)
return null;
if (await IsFundedAsync(task, ct) || (await tasks.ListChildrenAsync(task.Id, ct)).Count > 0)
return null;
var estimate = await estimator.EstimateAsync(task, ct);
if (estimate.Source is EstimateSource.AgentAverage or EstimateSource.None)
return null;
var threshold = agent.Budget.DailyCostUsd * share;
return estimate.Run.CostUsd > threshold
? Instruction(task, estimate, agent.Budget.DailyCostUsd, share)
: null;
}
public async Task<bool> IsFundedAsync(TaskItem task, CancellationToken ct)
=> pots is not null && await SpecialBudgets.FindAsync(task.Id, tasks, pots, ct) is not null;
/// <summary>What the planning run is told — ahead of the task itself.</summary>
public static string Instruction(TaskItem task, TaskEstimate estimate, decimal dailyLimit, decimal share)
{
var limit = dailyLimit.ToString("0.00", CultureInfo.InvariantCulture);
var percent = (share * 100).ToString("0", CultureInfo.InvariantCulture);
return $"""
[Planning run — from the system, not from the task's author]
Task {task.Id} is large: one run is expected to cost {FillWorkNotice.Describe(estimate)}, more than {percent} % of your daily budget of {limit} USD.
Do not start the work in this run. Propose one of two ways:
1. Split it: create parts with the taskboard (task_create, parent='{task.Id}'), each able to run on its own and small enough for a day. Parts that are not urgent can be fill work (fill_by). Task {task.Id} waits until its parts are done and then runs once more to bring them together.
2. Ask for a special budget: Escalation, action request_budget, with task='{task.Id}', amount_usd and reasoning. A human decides. The budget is for this task and its parts only.
End with one or two sentences: which way, and why.
""";
}
}
@@ -0,0 +1,133 @@
using System.Globalization;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
/// <summary>
/// #717: A special budget — an amount for exactly one task and everything below it, approved
/// by a human.
///
/// <para><b>The one exception to "budgets stay at the agent" (concept 8a).</b> It is a pot of
/// its own: runs in the task tree draw on it instead of the agent's daily limit, so a large
/// job neither eats the day's regular work nor fails at the daily limit halfway. It does not
/// raise the daily limit — work outside the tree never sees it — and it is never silent: it
/// exists only through a human's approval (staging), and its runs are marked in the cost
/// analysis. Once it is used up, the tree's runs count against the daily budget again, like
/// any other work.</para>
/// </summary>
public sealed record SpecialBudget
{
/// <summary>The task at the top of the tree the pot is for.</summary>
public string TaskId { get; init; } = "";
public decimal AmountUsd { get; init; }
public string Reason { get; init; } = "";
/// <summary>The agent that asked for it.</summary>
public string RequestedBy { get; init; } = "";
public DateTime ApprovedAt { get; init; }
}
/// <summary>#717: Where special budgets are kept, and what they have used so far.</summary>
public interface ISpecialBudgetRepository
{
Task<SpecialBudget?> GetAsync(string taskId, CancellationToken ct);
/// <summary>Sets or replaces the pot of a task — a second approval replaces the amount.</summary>
Task SetAsync(SpecialBudget budget, CancellationToken ct);
Task<IReadOnlyList<SpecialBudget>> ListAsync(CancellationToken ct);
/// <summary>What runs have drawn on the pot, from the usage records marked with it.</summary>
Task<decimal> SpentAsync(string taskId, CancellationToken ct);
}
public sealed class SqliteSpecialBudgetRepository(SqliteStorage storage) : ISpecialBudgetRepository
{
public async Task<SpecialBudget?> GetAsync(string taskId, CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT TaskId, AmountUsd, Reason, RequestedBy, ApprovedAt FROM SpecialBudgets WHERE TaskId = @id COLLATE NOCASE";
cmd.Parameters.AddWithValue("@id", taskId.Trim());
await using var reader = await cmd.ExecuteReaderAsync(ct);
return await reader.ReadAsync(ct) ? Read(reader) : null;
}
public Task SetAsync(SpecialBudget budget, CancellationToken ct)
=> storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO SpecialBudgets (TaskId, AmountUsd, Reason, RequestedBy, ApprovedAt)
VALUES (@id, @amount, @reason, @by, @at)
ON CONFLICT(TaskId) DO UPDATE SET
AmountUsd = excluded.AmountUsd, Reason = excluded.Reason,
RequestedBy = excluded.RequestedBy, ApprovedAt = excluded.ApprovedAt
""";
cmd.Parameters.AddWithValue("@id", budget.TaskId.Trim());
cmd.Parameters.AddWithValue("@amount", budget.AmountUsd.ToString(CultureInfo.InvariantCulture));
cmd.Parameters.AddWithValue("@reason", budget.Reason);
cmd.Parameters.AddWithValue("@by", budget.RequestedBy);
cmd.Parameters.AddWithValue("@at", budget.ApprovedAt.ToUniversalTime().ToString("O"));
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public async Task<IReadOnlyList<SpecialBudget>> ListAsync(CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT TaskId, AmountUsd, Reason, RequestedBy, ApprovedAt FROM SpecialBudgets ORDER BY ApprovedAt DESC";
var result = new List<SpecialBudget>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
result.Add(Read(reader));
return result;
}
public async Task<decimal> SpentAsync(string taskId, CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
// Amounts are text (exact decimals); summed here, not in SQL.
cmd.CommandText = "SELECT CostUsd FROM RunUsage WHERE BudgetPot = @id COLLATE NOCASE";
cmd.Parameters.AddWithValue("@id", taskId.Trim());
var spent = 0m;
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
spent += decimal.TryParse(reader.GetString(0), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : 0m;
return spent;
}
private static SpecialBudget Read(Microsoft.Data.Sqlite.SqliteDataReader r) => new()
{
TaskId = r.GetString(0),
AmountUsd = decimal.TryParse(r.GetString(1), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : 0m,
Reason = r.GetString(2),
RequestedBy = r.GetString(3),
ApprovedAt = DateTime.TryParse(r.GetString(4), null, DateTimeStyles.RoundtripKind, out var at) ? at : DateTime.MinValue
};
}
/// <summary>#717: Finding the pot a task draws on — its own, or the nearest one above it.</summary>
public static class SpecialBudgets
{
/// <summary>A tree deeper than this is a mistake, not a plan; the walk stops there.</summary>
private const int MaxDepth = 32;
public static async Task<SpecialBudget?> FindAsync(
string taskId, ITaskRepository tasks, ISpecialBudgetRepository pots, CancellationToken ct)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var current = taskId.Trim();
for (var depth = 0; depth < MaxDepth && current.Length > 0 && seen.Add(current); depth++)
{
if (await pots.GetAsync(current, ct) is { } pot)
return pot;
current = (await tasks.GetAsync(current, ct))?.ParentId?.Trim() ?? "";
}
return null;
}
}
+66 -14
View File
@@ -54,6 +54,12 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context.</summary>
public Cases.ICaseDesk? CaseDesk { get; set; }
/// <summary>
/// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the
/// daily limits; without this, every run counts against the day.
/// </summary>
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; set; }
private Organisation.OrgChart CurrentOrganisation()
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
@@ -192,7 +198,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
// #716: Die Reservierung gilt, bis der Verbrauch erfasst ist (Ende der Methode).
var (denied, reservation) = await CheckBudgetAsync(agentConfig, source ?? AuditSource.Direct, taskId, externalCt);
var (denied, reservation, pot) = await CheckBudgetAsync(agentConfig, source ?? AuditSource.Direct, taskId, externalCt);
if (denied is not null)
return denied;
using var budgetReservation = reservation;
@@ -205,8 +211,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var recorder = CreateRecorder(agentConfig);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId, chain.CallerRunId);
await RecordUsageAsync(agentConfig, result, pot);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId, chain.CallerRunId, pot);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result;
}
@@ -478,7 +484,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
if (RefuseWhileShuttingDown(agentConfig) is { } zu)
return zu;
var (denied, reservation) = await CheckBudgetAsync(agentConfig, source, taskId, externalCt);
var (denied, reservation, pot) = await CheckBudgetAsync(agentConfig, source, taskId, externalCt);
if (denied is not null)
return denied;
using var budgetReservation = reservation;
@@ -528,8 +534,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var recorder = CreateRecorder(agentConfig);
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId, chain.CallerRunId);
await RecordUsageAsync(agentConfig, result, pot);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId, chain.CallerRunId, pot);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result;
}
@@ -1693,6 +1699,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Organisation = CurrentOrganisation(),
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
Cases = CaseDesk,
SpecialBudgets = SpecialBudgets,
// #697: Where something an agent hands on came from — known to the engine, not claimed by the agent.
RunId = runId,
RunTaskId = runId is null ? null : Activity.Find(runId)?.TaskId
@@ -1748,16 +1755,21 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>
/// Prüft das Tagesbudget. Gibt ein Ergebnis zurück, wenn der Lauf nicht stattfinden
/// darf — sonst null und, bei gesetzter Grenze, die Reservierung des Laufs (#716).
/// #717: Liegt die Aufgabe in einem Baum mit Sonderbudget, zieht der Lauf den Topf statt
/// der Tagesgrenzen; dessen Kennung kommt als <c>Pot</c> zurück.
/// </summary>
private async Task<(AgentRunResult? Denied, IDisposable? Reservation)> CheckBudgetAsync(
private async Task<(AgentRunResult? Denied, IDisposable? Reservation, string? Pot)> CheckBudgetAsync(
AgentConfig agentConfig, string? source, string? taskId, CancellationToken ct)
{
if (_budgetGuard is null)
return (null, null);
return (null, null, null);
if (await ReservePotAsync(agentConfig, taskId, ct) is { } potRun)
return (null, potRun.Reservation, potRun.Pot);
var (status, reservation) = await _budgetGuard.ReserveAsync(agentConfig, InstanceBudget, ct);
if (status.IsAllowed)
return (null, reservation);
return (null, reservation, null);
// #608: Ist ein Budget erschoepft, lehnt es jeden weiteren Lauf des Tages ab. Einmal je
// Agent, Grenze und Tag als Warnung genuegt - danach steht jede Ablehnung als Beleg da.
@@ -1785,9 +1797,47 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
await RecordReceiptAsync(Guid.NewGuid().ToString("N"), agentConfig, result, source, taskId);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return (result, null);
return (result, null, null);
}
/// <summary>
/// #717: The special budget of the task's tree, if there is one and it has something left.
/// A used-up pot is not a refusal — the tree's runs count against the day again, like any
/// other work, and that is said once in the log.
/// </summary>
private async Task<(IDisposable Reservation, string Pot)?> ReservePotAsync(
AgentConfig agentConfig, string? taskId, CancellationToken ct)
{
if (_budgetGuard is null || SpecialBudgets is not { } pots || _taskRepository is null || string.IsNullOrWhiteSpace(taskId))
return null;
try
{
if (await Budget.SpecialBudgets.FindAsync(taskId, _taskRepository, pots, ct) is not { } pot)
return null;
var spent = await pots.SpentAsync(pot.TaskId, ct);
if (await _budgetGuard.ReservePotAsync(pot, spent, agentConfig.AgentId, ct) is { } reservation)
return (reservation, pot.TaskId);
if (_potUsedUp.TryAdd(pot.TaskId, 0))
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget").LogWarning(
"Special budget of {TaskId} is used up ({Spent:F2} of {Amount:F2} USD) — runs in its tree count against the daily budget again",
pot.TaskId, spent, pot.AmountUsd);
return null;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Without the pot the run is checked like any other — never unchecked.
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget")
.LogWarning(ex, "Special budget lookup failed for task {TaskId}", taskId);
return null;
}
}
/// <summary>#717: Pots already reported as used up — said once, not at every run.</summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _potUsedUp = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Welche Budgetgrenze je Agent zuletzt an welchem Tag als Warnung gemeldet wurde.</summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateOnly> _budgetGewarnt = new();
@@ -1795,7 +1845,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// Schreibt den Verbrauch eines Laufs fort. Fehler hierbei dürfen den Lauf nicht
/// nachträglich scheitern lassen — die eigentliche Arbeit ist bereits getan.
/// </summary>
private async Task RecordUsageAsync(AgentConfig agentConfig, AgentRunResult result)
private async Task RecordUsageAsync(AgentConfig agentConfig, AgentRunResult result, string? budgetPot = null)
{
if (_usageRepository is null)
return;
@@ -1822,7 +1872,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Status = result.Status.ToString(),
StepCount = result.StepCount,
DurationMs = (long)result.Duration.TotalMilliseconds,
OccurredAt = DateTime.Now
OccurredAt = DateTime.Now,
BudgetPot = budgetPot ?? ""
}, CancellationToken.None);
}
catch (Exception ex)
@@ -1920,7 +1971,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
private async Task RecordReceiptAsync(
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId,
string? callerRunId = null)
string? callerRunId = null, string? budgetPot = null)
{
if (_auditRepository is null)
return;
@@ -1951,7 +2002,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
DurationMs = (long)result.Duration.TotalMilliseconds,
ResultRef = Cap(result.FinalMessage ?? "", 500),
OccurredAt = DateTime.Now,
CallerRunId = callerRunId
CallerRunId = callerRunId,
BudgetPot = budgetPot
}, CancellationToken.None);
}
catch (Exception ex)
+10 -3
View File
@@ -93,12 +93,17 @@ public readonly record struct Freigabeeinstufung(StagingDecision Entscheidung, s
/// </summary>
public static class FreigabeRegeln
{
/// <summary>Was einen Agenten verändert. Bleibt ausnahmslos freigabepflichtig.</summary>
/// <summary>
/// Was einen Agenten verändert — und #717 ein Sonderbudget: Geld über die Tagesgrenze
/// hinaus entscheidet immer ein Mensch. Bleibt ausnahmslos freigabepflichtig, auch gegen
/// Werkzeugkonfiguration und Dauerfreigabe.
/// </summary>
public static IReadOnlySet<string> ImmerFreigabe { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"AgentEditor.update_identity",
"AgentEditor.update_soul",
"AgentEditor.create_agent"
"AgentEditor.create_agent",
"Escalation.request_budget"
};
public static bool IstImmerFreigabe(string tool, string? operation)
@@ -109,7 +114,9 @@ public static class FreigabeRegeln
StagingDecision? werkzeugVorgabe, StagingPolicy policy, bool dauerfreigabe = false)
{
if (IstImmerFreigabe(tool, operation))
return new(StagingDecision.Approve, "Änderungen an Agenten brauchen immer eine Freigabe.");
return new(StagingDecision.Approve, string.Equals(tool, "Escalation", StringComparison.OrdinalIgnoreCase)
? "Ein Sonderbudget entscheidet immer ein Mensch."
: "Änderungen an Agenten brauchen immer eine Freigabe.");
if (string.IsNullOrWhiteSpace(operation))
return Nach(policy.Decide(tool, null), "der Standardregel");
@@ -373,6 +373,15 @@ public sealed class SqliteStorage
DecidedAt TEXT NOT NULL,
Json TEXT NOT NULL
);
-- #717: Special budgets — one pot per task tree, approved by a human.
CREATE TABLE IF NOT EXISTS SpecialBudgets (
TaskId TEXT PRIMARY KEY COLLATE NOCASE,
AmountUsd TEXT NOT NULL,
Reason TEXT NOT NULL DEFAULT '',
RequestedBy TEXT NOT NULL DEFAULT '',
ApprovedAt TEXT NOT NULL
);
""";
cmd.ExecuteNonQuery();
@@ -418,6 +427,13 @@ public sealed class SqliteStorage
SpalteErgaenzen(connection, "Tasks", "EstimateUsd", "TEXT NULL");
SpalteErgaenzen(connection, "Tasks", "FillAlertedAt", "TEXT NULL");
// #717: A large task got its planning run — split or special budget, the agent proposed.
SpalteErgaenzen(connection, "Tasks", "LargePlannedAt", "TEXT NULL");
// #717: Runs that drew on a special budget — outside the daily limits, visible apart.
SpalteErgaenzen(connection, "RunUsage", "BudgetPot", "TEXT NOT NULL DEFAULT ''");
SpalteErgaenzen(connection, "RunReceipts", "BudgetPot", "TEXT NULL");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand();
index.CommandText = """
@@ -53,7 +53,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
_clock = clock ?? TimeProvider.System;
}
public async Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
=> DispatchAsync(task, null, ct);
/// <summary>
/// #717: With an instruction from the code ahead of the task — for a large task, that the
/// run plans instead of starting the work.
/// </summary>
public async Task<TaskLauf> DispatchAsync(TaskItem task, string? instruction, CancellationToken ct)
{
// Poll-Tasks (tool_job) ticken ein Tool statt einen Agenten direkt zu starten.
if (task.Type == TaskItemType.ToolJob)
@@ -61,6 +68,8 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
var agents = _agents();
var message = BuildMessage(task, await WhyAsync(task, ct));
if (!string.IsNullOrWhiteSpace(instruction))
message = instruction.Trim() + "\n\n---\n\n" + message;
var kind = TaskAssignee.KindOf(task.Assignee);
AgentRunResult result;
@@ -100,6 +100,9 @@ public interface ITaskRepository
/// </summary>
Task<bool> MarkFillAlertedAsync(string id, DateTime now, CancellationToken ct);
/// <summary>#717: Records that a large task had its planning run.</summary>
Task<bool> MarkLargePlannedAsync(string id, DateTime now, CancellationToken ct);
/// <summary>
/// Reconciliation beim Start: verwaiste Claims (älter als <paramref name="leaseCutoff"/>)
/// lösen und die betroffenen Aufgaben von <see cref="TaskItemStatus.InProgress"/> zurück
@@ -271,6 +271,17 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> MarkLargePlannedAsync(string id, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE Tasks SET LargePlannedAt = @now, UpdatedAt = @now WHERE Id = @id";
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@id", id);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
@@ -363,7 +374,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId, OriginRunId, OriginTaskId, "
+ "FillBy, EstimateUsd, FillAlertedAt";
+ "FillBy, EstimateUsd, FillAlertedAt, LargePlannedAt";
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{
@@ -451,7 +462,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
FillBy = r.IsDBNull(30) ? null : Parse(r.GetString(30)),
EstimateUsd = r.IsDBNull(31) ? null
: decimal.Parse(r.GetString(31), System.Globalization.CultureInfo.InvariantCulture),
FillAlertedAt = r.IsDBNull(32) ? null : Parse(r.GetString(32))
FillAlertedAt = r.IsDBNull(32) ? null : Parse(r.GetString(32)),
LargePlannedAt = r.IsDBNull(33) ? null : Parse(r.GetString(33))
};
}
+6
View File
@@ -274,6 +274,12 @@ public sealed record TaskItem
/// </summary>
public DateTime? FillAlertedAt { get; init; }
/// <summary>
/// #717: When a large task had its planning run — split or special budget, the agent
/// proposed. Planned once; afterwards it runs like any other task.
/// </summary>
public DateTime? LargePlannedAt { get; init; }
/// <summary>#717: One-time work with a deadline instead of an appointment.</summary>
public bool IsFillWork => FillBy is not null && !IsRecurring;
+133 -2
View File
@@ -33,6 +33,26 @@ public interface ITaskDispatcher
{
/// <summary>Führt die Aufgabe aus und meldet, wie der Lauf ausging.</summary>
Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct);
/// <summary>
/// #717: Runs the task with an instruction from the code ahead of it. Without an
/// implementation the instruction is dropped and the task runs as usual.
/// </summary>
Task<TaskLauf> DispatchAsync(TaskItem task, string? instruction, CancellationToken ct)
=> DispatchAsync(task, ct);
}
/// <summary>
/// #717: Whether a task is too large to simply start — its estimate is a large share of the
/// agent's daily budget. Then its run plans instead: split it, or ask for a special budget.
/// </summary>
public interface ILargeTaskCheck
{
/// <summary>The instruction for the planning run, or <c>null</c> if the task can simply run.</summary>
Task<string?> PlanningInstructionAsync(TaskItem task, CancellationToken ct);
/// <summary>Whether the task's tree has a special budget — then, after planning, it runs.</summary>
Task<bool> IsFundedAsync(TaskItem task, CancellationToken ct);
}
/// <summary>
@@ -170,6 +190,12 @@ public sealed class TaskScanner : IAsyncDisposable
private readonly Func<TimeSpan> _fillWarnBefore;
/// <summary>#717: Whether a task is too large to simply start.</summary>
private readonly ILargeTaskCheck? _largeCheck;
/// <summary>#717: Leaves a note on a task — why the scanner holds it.</summary>
private readonly Func<string, string, CancellationToken, Task>? _noteOnTask;
/// <summary>#717: Fill work already reported as past its deadline — once per task.</summary>
private readonly HashSet<string> _warnedOverdueFill = [];
@@ -205,8 +231,12 @@ public sealed class TaskScanner : IAsyncDisposable
ITaskTreeWatch? treeWatch = null,
IFillGate? fillGate = null,
Func<TaskItem, CancellationToken, Task>? askAboutFill = null,
Func<TimeSpan>? fillWarnBefore = null)
Func<TimeSpan>? fillWarnBefore = null,
ILargeTaskCheck? largeTaskCheck = null,
Func<string, string, CancellationToken, Task>? noteOnTask = null)
{
_largeCheck = largeTaskCheck;
_noteOnTask = noteOnTask;
_treeWatch = treeWatch;
_fillGate = fillGate;
_askAboutFill = askAboutFill;
@@ -616,10 +646,15 @@ public sealed class TaskScanner : IAsyncDisposable
private async Task ProcessCoreAsync(TaskItem task, string token, CancellationToken ct)
{
// #717: A large task is not simply started — its run plans first.
var planning = await PlanningInstructionAsync(task, ct);
TaskLauf lauf;
try
{
lauf = await _dispatcher.DispatchAsync(task, ct);
lauf = planning is null
? await _dispatcher.DispatchAsync(task, ct)
: await _dispatcher.DispatchAsync(task, planning, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -656,6 +691,12 @@ public sealed class TaskScanner : IAsyncDisposable
_zurueckgestellt.TryRemove(task.Id, out _);
if (planning is not null)
{
await SettlePlanningAsync(task, token, lauf, now, ct);
return;
}
// Endstatus:
// - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin
// wird nicht wiederholt).
@@ -682,9 +723,99 @@ public sealed class TaskScanner : IAsyncDisposable
await _repo.ClearBudgetMissAsync(task.Id, now, ct);
if (ok && final == TaskItemStatus.Done)
{
await UnblockDependentsAsync(task.Id, now, ct);
await ResumeLargeParentAsync(task, now, ct);
}
}
/// <summary>
/// #717: The planning instruction for a large one-time task that has not been planned yet.
/// A failing check costs the check, not the run.
/// </summary>
private async Task<string?> PlanningInstructionAsync(TaskItem task, CancellationToken ct)
{
if (_largeCheck is null || task.IsRecurring || task.LargePlannedAt is not null || task.Type == TaskItemType.ToolJob)
return null;
try
{
return await _largeCheck.PlanningInstructionAsync(task, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Large-task check failed for {TaskId} — it runs as usual", task.Id);
return null;
}
}
/// <summary>
/// #717: After the planning run of a large task. Split into parts → it waits for them.
/// A special budget approved meanwhile → it runs. Otherwise it waits, blocked, for the
/// human's decision on the budget — never silently: the task says why.
///
/// <para>The occurrence is given back first: the planning run did not do the work, and a
/// one-time task whose marker is used up would never run again.</para>
/// </summary>
private async Task SettlePlanningAsync(TaskItem task, string token, TaskLauf lauf, DateTime now, CancellationToken ct)
{
if (!await _repo.ReleaseClaimAsync(task.Id, token, task.LastOccurrence, now, ct))
return;
if (lauf != TaskLauf.Erledigt)
return; // the planning did not take place or failed — the next due tick plans again
await _repo.MarkLargePlannedAsync(task.Id, now, ct);
var parts = (await _repo.ListChildrenAsync(task.Id, ct)).Count(IsOpen);
string? note = null;
if (parts > 0)
{
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Blocked, now, ct);
note = $"Large task: split into {parts} part(s). It runs again once they are done.";
}
else if (_largeCheck is not null && await _largeCheck.IsFundedAsync(task, ct))
{
_logger.LogInformation("Large task {TaskId} has a special budget — it runs on it", task.Id);
}
else
{
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Blocked, now, ct);
note = "Large task: not started. It waits for a special budget to be approved (staging), "
+ "or for a human to split it or set it back to todo.";
}
if (note is not null)
{
_logger.LogInformation("Task {TaskId}: {Note}", task.Id, note);
if (_noteOnTask is not null)
{
try { await _noteOnTask(task.Id, note, ct); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Could not note on task {TaskId}", task.Id);
}
}
}
}
/// <summary>
/// #717: A part of a split large task is done. When it was the last open one, the large
/// task is taken up again — to bring the parts together.
/// </summary>
private async Task ResumeLargeParentAsync(TaskItem part, DateTime now, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(part.ParentId)
|| await _repo.GetAsync(part.ParentId, ct) is not { Status: TaskItemStatus.Blocked, LargePlannedAt: not null } parent)
return;
if ((await _repo.ListChildrenAsync(parent.Id, ct)).Any(IsOpen))
return;
await _repo.SetStatusAsync(parent.Id, TaskItemStatus.Todo, now, ct);
_logger.LogInformation("Large task {TaskId}: all parts done — it runs again to bring them together", parent.Id);
}
private static bool IsOpen(TaskItem task)
=> task.Status is not (TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived);
/// <summary>
/// #716: The budget refused the run. A one-time task is carried over to the next day
/// with its occurrence intact; a recurring one follows the agent's policy — the
@@ -49,6 +49,9 @@ public sealed record AgentToolContext(
/// <summary>#689: The desk for escalations, conflicts and decisions. <c>null</c> outside the engine.</summary>
public Cases.ICaseDesk? Cases { get; init; }
/// <summary>#717: Special budgets — set up by an approved request_budget.</summary>
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
/// <summary>#697: The run this call belongs to — for stamping where something came from.</summary>
public string? RunId { get; init; }
@@ -369,6 +369,7 @@
"tasks.fillBy": "Füllarbeit, spätestens bis",
"tasks.fillByHint": "Leer lassen für reguläre Arbeit. Füllarbeit läuft auf Budget, das der Tag übrig lässt; ein Datum meint das Ende dieses Tages.",
"tasks.estimateUsd": "Schätzung je Lauf (USD)",
"tasks.specialBudget": "Sonderbudget für diese Aufgabe und ihre Teile: {spent} von {amount} verbraucht",
"services.title": "Dienste",
"services.newService": "Neuer Dienst",
@@ -769,6 +770,9 @@
"timeline.groupBy.agent": "Agent",
"timeline.groupBy.model": "Modell",
"timeline.groupBy.source": "Ereignis",
"timeline.groupBy.budget": "Budget",
"timeline.budget.daily": "Tagesbudget",
"timeline.budget.special": "Sonderbudget {id}",
"timeline.bucket": "Je",
"timeline.bucket.hour": "Stunde",
"timeline.bucket.day": "Tag",
@@ -369,6 +369,7 @@
"tasks.fillBy": "Fill work, at the latest by",
"tasks.fillByHint": "Leave empty for regular work. Fill work runs on budget the day leaves unused; a date means the end of that day.",
"tasks.estimateUsd": "Estimate per run (USD)",
"tasks.specialBudget": "Special budget for this task and its parts: {spent} of {amount} used",
"services.title": "Services",
"services.newService": "New Service",
@@ -769,6 +770,9 @@
"timeline.groupBy.agent": "Agent",
"timeline.groupBy.model": "Model",
"timeline.groupBy.source": "Event",
"timeline.groupBy.budget": "Budget",
"timeline.budget.daily": "Daily budget",
"timeline.budget.special": "Special budget {id}",
"timeline.bucket": "Per",
"timeline.bucket.hour": "Hour",
"timeline.bucket.day": "Day",
@@ -108,6 +108,9 @@ export async function rendereAufgaben() {
await ladeTaskboard();
}
/** #717: Special budgets by task id — filled by ladeTaskboard. */
let sonderbudgets = new Map();
async function ladeTaskboard() {
const container = document.getElementById("aufgaben-bereich-inhalt");
if (!container) return;
@@ -117,7 +120,12 @@ async function ladeTaskboard() {
);
try {
const alle = (await api("/tasks?limit=200")) || [];
const [alle, toepfe] = await Promise.all([
api("/tasks?limit=200").then((r) => r || []),
// #717: special budgets, shown on the task they are for; without them the board still loads.
api("/special-budgets").then((r) => r || []).catch(() => [])
]);
sonderbudgets = new Map(toepfe.map((b) => [b.taskId.toLowerCase(), b]));
const offen = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
const tasks = offen.filter((t) => !brettFilter || (brettFilter === "-" ? !t.board : t.board === brettFilter));
container.replaceChildren();
@@ -174,6 +182,11 @@ function brettAuswahl(tasks) {
erstelleElement("span", { text: t("tasks.board") }), auswahl);
}
/** #717: A budget or an estimate is an amount of money, not a run's cost — cents are enough. */
function betrag(usd) {
return "$" + Number(usd ?? 0).toFixed(2);
}
/**
* #717: The date for the input field. A date is stored as the end of that day (the next
* midnight, UTC), so one millisecond earlier is the day that was meant.
@@ -247,10 +260,19 @@ function erstelleAufgabenKarte(task) {
if (task.fillAlertedAt) teile.push(t("tasks.fillAlerted", { time: formatiereDatumZeit(task.fillAlertedAt) }));
zeilen.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: `◔ ${teile.join(" · ")}` }));
}
// #717: A special budget is the one exception to the daily limit — never silent.
const topf = sonderbudgets.get(task.id.toLowerCase());
if (topf) {
zeilen.push(erstelleElement("p", {
klasse: "text-klein",
title: `${topf.reason} (${topf.requestedBy}, ${formatiereDatumZeit(topf.approvedAt)})`,
text: `◆ ${t("tasks.specialBudget", { spent: betrag(topf.spentUsd), amount: betrag(topf.amountUsd) })}`
}));
}
if (task.estimateUsd != null) {
zeilen.push(erstelleElement("p", {
klasse: "gedaempft text-klein",
text: t("tasks.estimate", { usd: formatiereKosten(task.estimateUsd) })
text: t("tasks.estimate", { usd: betrag(task.estimateUsd) })
}));
}
@@ -91,11 +91,22 @@ function buildControls(filters, bucket, groupBy, metric) {
bar.append(
group("metric", ["cost", "tokens", "runs"], metric),
group("groupBy", ["agent", "model", "source"], groupBy),
group("groupBy", ["agent", "model", "source", "budget"], groupBy),
group("bucket", ["hour", "day"], bucket));
return bar;
}
/** A group's label — sources translated, #717 budget pots named. */
function groupLabel(groupBy, group) {
if (groupBy === "source") return sourceLabel(group);
if (groupBy === "budget") {
return group.startsWith("special:")
? t("timeline.budget.special", { id: group.slice("special:".length) })
: t("timeline.budget.daily");
}
return group;
}
function bucketsBetween(from, to, bucket) {
const step = bucket === "day" ? 864e5 : 36e5;
const start = new Date(from);
@@ -172,7 +183,7 @@ function buildChart(points, events, filters, bucket, groupBy, metric) {
y -= h;
const rect = svg("rect", {
x: x + 1, y, width: barWidth - 2, height: h, fill: color(p.group), class: "balken", tabindex: 0
}, title(`${formatBucket(buckets[i], bucket)} · ${groupBy === "source" ? sourceLabel(p.group) : p.group}: ` +
}, title(`${formatBucket(buckets[i], bucket)} · ${groupLabel(groupBy, p.group)}: ` +
`${formatValue(valueOf(p), metric)} (${t("timeline.tooltipRuns", { n: p.runs })})`));
rect.addEventListener("click", () => openBucket(filters, buckets[i], step, groupBy, p.group));
chart.append(rect);
@@ -201,7 +212,7 @@ function buildChart(points, events, filters, bucket, groupBy, metric) {
const total = points.filter((p) => p.group === group).reduce((sum, p) => sum + valueOf(p), 0);
legend.append(erstelleElement("span", { klasse: "legende-eintrag" },
erstelleElement("span", { klasse: "legende-farbe", stil: { background: color(group) } }),
erstelleElement("span", { text: `${groupBy === "source" ? sourceLabel(group) : group}: ${formatValue(total, metric)}` })));
erstelleElement("span", { text: `${groupLabel(groupBy, group)}: ${formatValue(total, metric)}` })));
}
if (events.length > 0) {
legend.append(
@@ -229,6 +240,7 @@ function openBucket(filters, start, step, groupBy, group) {
delete next.bucket;
delete next.groupBy;
delete next.metric;
next[groupBy === "source" ? "source" : groupBy] = group;
// #717: There is no filter by budget pot — the runs of the bucket are shown unfiltered.
if (groupBy !== "budget") next[groupBy === "source" ? "source" : groupBy] = group;
applyFilters("/verbrauch/runs", next);
}
@@ -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}");
+3
View File
@@ -135,6 +135,9 @@ public sealed record TaskDto(
decimal? EstimateUsd = null,
DateTime? FillAlertedAt = null);
/// <summary>#717: A special budget — the amount a human approved for a task tree, and what it used.</summary>
public sealed record SpecialBudgetDto(string TaskId, decimal AmountUsd, decimal SpentUsd, string Reason, string RequestedBy, DateTime ApprovedAt);
/// <summary>
/// #716: Runs of this task the budget refused — only present while something is open
/// (misses since the last run that took place, a pending catch-up or a deferral).
@@ -55,7 +55,7 @@ public static class AnalyticsEndpoints
var points = await insights.Analytics.SeriesAsync(
ParseQuery(request),
bucket == "day" ? "day" : "hour",
groupBy is "model" or "source" ? groupBy : "agent",
groupBy is "model" or "source" or "budget" ? groupBy : "agent",
TimeSpan.FromMinutes(Math.Clamp(tzOffset, -14 * 60, 14 * 60)),
ct);
return Results.Ok(points.Select(p => new SeriesPointDto(p.BucketStart, p.Group, p.Runs, p.Tokens, p.CostUsd)).ToList());
@@ -187,7 +187,7 @@ public static class AnalyticsEndpoints
private static RunRowDto Row(Core.Audit.RunReceipt r, string? taskTitle)
=> new(r.RunId, r.AgentId, r.Model, r.Source, r.TaskId, taskTitle, r.Status,
r.StepCount, r.PromptTokens, r.CompletionTokens, r.CachedTokens, r.CostUsd, r.CostIsKnown,
r.DurationMs, r.ResultRef, r.OccurredAt, r.CallerRunId);
r.DurationMs, r.ResultRef, r.OccurredAt, r.CallerRunId, r.BudgetPot);
private static RunDetailDto ToDto(RunDetail d) => new(
d.RunId,
@@ -222,7 +222,9 @@ public sealed record RunRowDto(
string Status, int StepCount, int PromptTokens, int CompletionTokens, int CachedTokens,
decimal CostUsd, bool CostIsKnown, long DurationMs, string Result, DateTime OccurredAt,
// #697: the run this one was delegated from.
string? CallerRunId = null);
string? CallerRunId = null,
// #717: the task whose special budget the run drew on.
string? BudgetPot = null);
public sealed record RunTotalsDto(
int Runs, long PromptTokens, long CompletionTokens, long CachedTokens,
@@ -287,6 +287,18 @@ internal static class ApiEndpoints
tasks.MapPost("/{id}/run", async (AppHost host, string id, CancellationToken ct) =>
(await host.TaskAdmin.RunNowAsync(id, ct)).ToResponse());
// #717: Special budgets with what they have used — shown on the tasks they are for.
api.MapGet("/special-budgets", async (AppHost host, CancellationToken ct) =>
{
if (host.SpecialBudgets is not { } pots)
return Results.Ok(Array.Empty<SpecialBudgetDto>());
var list = new List<SpecialBudgetDto>();
foreach (var pot in await pots.ListAsync(ct))
list.Add(new SpecialBudgetDto(pot.TaskId, pot.AmountUsd, await pots.SpentAsync(pot.TaskId, ct),
pot.Reason, pot.RequestedBy, pot.ApprovedAt));
return Results.Ok(list);
});
tasks.MapGet("/{id}", async (AppHost host, string id, CancellationToken ct) =>
{
if (host.Board is not { } board)