@sema-agent/core
Version:
Stateless, task-oriented AI agent core
112 lines (111 loc) • 6.26 kB
TypeScript
import type { TaskResult } from "../core/types.js";
/**
* Shared CUMULATIVE-STATS constructor for the multi-leg agent compositions (cascade rungs, repair-loop
* attempts, teacher student attempts). All three run `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`).
* Three independent copies of this literal is how that hole survived in one copy while the other two were
* fixed — hence one constructor, three call sites.
*
* OWN and NESTED stay DISJOINT (the cascade R-5 / teacher A-7 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) EXACTLY as the caller wants it published. Callers differ on the
* zero case on purpose and this constructor does not adjudicate: cascade/repair-loop publish a plain `0`
* (a run happened, it cost nothing measurable), teacher collapses `0` to `undefined` (the RB-368
* "unpriced spend is absent, not free" posture). Unifying the two is a BEHAVIOR change, not a refactor.
*
* Ignored entirely when {@link anyLegUnpriced} is set — see there.
*/
costMicroUsd: number | undefined;
/**
* 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;
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 / teacher each hand-maintain the same dozen `+=` lines, so a new stats field still
* has to be remembered in four places — the same duplication one level down from the hole this module
* exists to close. New call sites take this factory instead. Migrating the three originals onto it is a
* separate change (their folds are equivalent to this one today, but rewriting three 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;