@sema-agent/core
Version:
Stateless, task-oriented AI agent core
112 lines (111 loc) • 6.14 kB
TypeScript
import type { TaskResult } from "../core/types.js";
/**
* Shared CUMULATIVE-STATS constructor for the multi-leg agent compositions (cascade rungs, repair-loop
* attempts, verify legs). Each runs `runner.runTask` MORE THAN ONCE and must report the OPERATION's totals,
* not the last leg's.
*
* The invariant this exists to hold in ONE place: **every field the accumulator tracks is OVERRIDDEN over
* the base stats**. A field left un-overridden silently leaks the LAST leg's value dressed up as a
* cumulative total (the cascade R1-m5 / R3 trap; repair-loop shipped exactly that hole on `humanReview`).
* Independent copies of this literal is how that hole survived in one copy while the others were fixed —
* hence one constructor.
*
* OWN and NESTED stay DISJOINT (the cascade R-5 convention): a consumer adds
* `stats.X + stats.nested.X` for the true total; nested cost is never folded into `costMicroUsd`.
*/
/** Cumulative delegated-subagent (nested) totals, summed across every leg. */
export interface CumulativeNestedTotals {
tokens: number;
turns: number;
tasks: number;
costMicroUsd: number;
}
/**
* Cumulative human-review (design/91 C2 axis) burden, summed across every leg. `gates` is the ordered
* concatenation of each leg's gate entries — the element shape is the canonical one published on
* {@link TaskResult}, so a leg's entry passes through with `toolName`/`toolArg` intact.
*/
export interface CumulativeHumanReviewTotals {
count: number;
totalWaitMs: number;
gates: NonNullable<TaskResult["stats"]["humanReview"]>["gates"];
}
/**
* The running totals a multi-leg composition accumulates. Every field is REQUIRED: an optional field here
* would re-open the exact "forgot to override one" hole this module exists to close — a caller that has
* nothing to report passes zeros.
*/
export interface CumulativeStatsAccumulator {
tokens: number;
turns: number;
promptTokens: number;
/** RB-457-a: the cache-INCLUSIVE input total — its own leg, no longer derivable from `promptTokens` (the
* cache-MISS sum). Also the cache-hit-rate denominator. */
totalInputTokens: number;
cachedTokens: number;
outputTokens: number;
cacheWriteTokens: number;
cacheWriteTokensLong: number;
/**
* Cumulative own LLM cost (micro-USD): a plain `0` when every leg was priced and cost nothing measurable.
*
* Ignored entirely when {@link anyLegUnpriced} is set — see there.
*/
costMicroUsd: number;
/**
* TRUE when ANY contributing leg published no `costMicroUsd`, i.e. it ran on a model with neither a
* `RunnerDeps.pricing` entry nor a `Model.cost` declaration (`stats.costMicroUsd` is contractually
* ABSENT there — types.ts / RB-368).
*
* Accumulating such a leg as `?? 0` publishes a total that is not one: the operation's real cost is
* unknown, and reporting the priced legs' partial sum makes "no price table" indistinguishable from
* "declared free" — the exact conflation the single-leg face refuses. So the knownness travels with the
* money: when this is set, `costMicroUsd` AND `costBreakdown` are both omitted from the cumulative
* stats, matching what a single unpriced `runTask` already reports. Token/turn totals are unaffected
* (they are knowable regardless of pricing).
*/
anyLegUnpriced: boolean;
/** Cumulative root-agent LLM cost for the `costBreakdown` taxonomy. */
llmRootMicroUsd: number;
/** Cumulative within-task compaction LLM cost for the `costBreakdown` taxonomy. */
compactionMicroUsd: number;
/** Cumulative side-call (permission classifier, mods) LLM cost — the "of which" line of `costBreakdown`, INSIDE
* `llmRootMicroUsd`, never a partition member. Undefined until a leg booked one: the cumulative key is then absent too. */
sideCallMicroUsd?: number;
nested: CumulativeNestedTotals;
humanReview: CumulativeHumanReviewTotals;
}
/**
* Build the cumulative stats over a base (the FINAL leg's stats — it carries the non-accumulated fields
* such as `model`) with every accumulated field overridden.
*
* `cacheHitRate` is RE-DERIVED from the cumulative cached/total-input totals rather than spread from the
* base: a ratio is not additive, so the last leg's rate is never the operation's rate.
*/
export declare function buildCumulativeStats(base: TaskResult["stats"], acc: CumulativeStatsAccumulator): TaskResult["stats"];
/** A running {@link CumulativeStatsAccumulator} with the per-leg fold built in. */
export interface CumulativeStatsTracker {
/** Fold ONE leg's stats into the running totals. Call once per leg, in dispatch order. */
add(s: TaskResult["stats"]): void;
/** Build the cumulative stats over `base` — conventionally the FINAL leg's stats (it carries the
* non-accumulated fields such as `model`). */
build(base: TaskResult["stats"]): TaskResult["stats"];
/** How many legs have been folded in. A composition that ran exactly ONE leg can use this to hand that
* leg's own stats back untouched: the cumulative account over one leg IS that leg, and re-deriving it
* would rewrite a caller's object (0 → `undefined` normalization, a re-derived `cacheHitRate`) for no
* gain. */
readonly legs: number;
}
/**
* Create a {@link CumulativeStatsTracker}.
*
* {@link buildCumulativeStats} made the OUTPUT literal single-source, but the INPUT fold stayed copied:
* cascade / repair-loop each hand-maintain the same dozen `+=` lines, so a new stats field still has to be
* remembered in three places — the same duplication one level down from the hole this module exists to
* close. New call sites take this factory instead. Migrating the two originals onto it is a separate change
* (their folds are equivalent to this one today, but rewriting shipped compositions is not part of a
* cost-accounting bug fix).
*
* OWN and NESTED stay DISJOINT here too: `add` never folds a leg's `nested.*` into the own totals.
*/
export declare function createCumulativeStatsTracker(): CumulativeStatsTracker;