using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
/// #717: Where an estimate came from — from the most to the least reliable.
public enum EstimateSource
{
/// The task's own runs, measured.
Measured,
/// Runs of its siblings — the other parts of the same split task.
Siblings,
/// What the task's author wrote down. A guess, and shown as one.
Stated,
/// The average run of the agent — says nothing about this task in particular.
AgentAverage,
/// Nothing to go on.
None
}
/// #717: What one run of a task is expected to cost, and how much that is worth.
public sealed record TaskEstimate(RunEstimate Run, EstimateSource Source, int Samples)
{
/// Stated or averaged over everything the agent does — not measured on this work.
public bool IsGuess => Source is EstimateSource.Stated or EstimateSource.AgentAverage or EstimateSource.None;
public static TaskEstimate Nothing { get; } = new(default, EstimateSource.None, 0);
}
///
/// #717: Estimates a run of one particular task — honestly.
///
/// Costs cannot be known in advance, and models judge their own effort badly. So the
/// code estimates from history where there is any, and says when it has none:
///
/// - the task's own runs (a fill task that already ran part of the way, or ran before),
/// - its siblings — the parts of the same split task, measured as soon as the first one ran,
/// - the author's estimate (), marked as a guess,
/// - the agent's average run, also a guess.
///
/// After the first run the measured value replaces the guess — nothing has to be
/// updated for that, the next estimate simply finds the run.
///
public sealed class TaskCostEstimator
{
/// How many runs are averaged — recent enough to follow a change, enough to smooth one outlier.
public const int Sample = 10;
private readonly IAuditRepository? _audit;
private readonly ITaskRepository _tasks;
private readonly BudgetGuard _guard;
public TaskCostEstimator(IAuditRepository? audit, ITaskRepository tasks, BudgetGuard guard)
{
_audit = audit;
_tasks = tasks;
_guard = guard;
}
public async Task EstimateAsync(TaskItem task, CancellationToken ct)
{
if (_audit is not null)
{
var own = Measured(await _audit.ListReceiptsForTaskAsync(task.Id, ct));
if (own.Count > 0)
return Average(own, EstimateSource.Measured);
if (!string.IsNullOrWhiteSpace(task.ParentId))
{
var siblingRuns = new List();
foreach (var sibling in await _tasks.ListChildrenAsync(task.ParentId, ct))
{
if (sibling.Id == task.Id)
continue;
siblingRuns.AddRange(Measured(await _audit.ListReceiptsForTaskAsync(sibling.Id, ct)));
}
if (siblingRuns.Count > 0)
return Average(siblingRuns.OrderByDescending(r => r.OccurredAt).Take(Sample).ToList(), EstimateSource.Siblings);
}
}
var agentId = TaskAssignee.AgentId(task.Assignee);
var agentAverage = agentId.Length == 0 ? default : await _guard.EstimateRunAsync(agentId, ct);
if (task.EstimateUsd is { } stated)
{
// Tokens follow the agent's own price per token, where it has one — a token limit
// needs a token figure, and the author only wrote down money.
var tokens = agentAverage.CostUsd > 0
? (long)Math.Ceiling(stated / agentAverage.CostUsd * agentAverage.Tokens)
: 0;
return new TaskEstimate(new RunEstimate(stated, tokens), EstimateSource.Stated, 0);
}
return agentAverage == default
? TaskEstimate.Nothing
: new TaskEstimate(agentAverage, EstimateSource.AgentAverage, 0);
}
/// Runs that used something — a refusal or an abort before the first call says nothing about cost.
private static List Measured(IReadOnlyList receipts)
=> receipts.Where(r => r.PromptTokens + r.CompletionTokens > 0)
.OrderByDescending(r => r.OccurredAt)
.Take(Sample)
.ToList();
private static TaskEstimate Average(IReadOnlyList runs, EstimateSource source)
=> new(new RunEstimate(
runs.Average(r => r.CostUsd),
(long)Math.Ceiling(runs.Average(r => (double)(r.PromptTokens + r.CompletionTokens)))),
source, runs.Count);
}