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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user