Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export {
type CreateCodingAgentDriverOptions,
type RunCodingAgentAttemptOptions,
} from "./miner/driver-factory.js";
export * from "./miner/attempt-metering.js";
export * from "./plan-export.js";
export { countPlanStepsByStatus } from "./plan-step-stats.js";
export { countPlanSteps } from "./plan-step-count.js";
Expand Down
82 changes: 82 additions & 0 deletions packages/gittensory-engine/src/miner/attempt-metering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Per-attempt cost/turn metering (#4311): pure accumulation of a coding-agent attempt's usage
// (tokens / turns / wall-clock / cost) plus a pure evaluation of the running totals against a configured
// budget. Numbers only — no IO, no Date.now(), no randomness, no enforcement. This module reports whether a
// ceiling has been reached; it never stops, kills, or gates a driver. That enforcement wiring (graceful-stop
// vs. hard SIGKILL) and the attempt-log persistence (#4294) are separate, maintainer-owned concerns.
//
// Mirrors the governor/rate-limit.ts discipline ("computes numbers only... does NOT store state, schedule,
// or gate any write action"). Drivers report usage in different native shapes (CLI-subprocess vs. Agent-SDK);
// the caller normalizes each increment to the {tokens, turns, wallClockMs, costUsd} unit defined here.

/** One usage increment normalized to the metered unit. `costUsd` is 0 when a driver can't report spend. */
export type AttemptUsage = {
/** Model tokens consumed (prompt + completion) in this increment. */
tokens: number;
/** Agent turns (one full agent iteration) in this increment. */
turns: number;
/** Wall-clock milliseconds elapsed in this increment. */
wallClockMs: number;
/** Monetary cost (USD) of this increment; 0 when the driver does not report spend. */
costUsd: number;
};

/** Accumulated attempt totals — same shape as a single increment. */
export type AttemptMeterTotals = AttemptUsage;

/** Per-axis ceilings. An omitted axis means "no limit on that axis". */
export type AttemptBudget = {
maxTokens?: number;
maxTurns?: number;
maxWallClockMs?: number;
maxCostUsd?: number;
};

/** Which metered axes have reached or exceeded their ceiling. */
export type AttemptBudgetAxis = "tokens" | "turns" | "wallClockMs" | "costUsd";

export type AttemptMeterVerdict = {
totals: AttemptMeterTotals;
/** True when no axis has reached its ceiling. */
withinBudget: boolean;
/** The axes at/over ceiling (empty when within budget). */
breaches: AttemptBudgetAxis[];
};

const ZERO_USAGE: AttemptUsage = { tokens: 0, turns: 0, wallClockMs: 0, costUsd: 0 };

/** Fold one usage increment into a running total. Pure — returns a new total, mutates nothing. */
export function accumulateAttemptUsage(
total: AttemptMeterTotals,
next: AttemptUsage,
): AttemptMeterTotals {
return {
tokens: total.tokens + next.tokens,
turns: total.turns + next.turns,
wallClockMs: total.wallClockMs + next.wallClockMs,
costUsd: total.costUsd + next.costUsd,
};
}

/** Sum a sequence of usage increments from zero. Pure. */
export function meterAttemptUsage(increments: readonly AttemptUsage[]): AttemptMeterTotals {
return increments.reduce(accumulateAttemptUsage, { ...ZERO_USAGE });
}

/**
* Evaluate accumulated totals against a budget. An axis is breached when its total is **at or above** its
* ceiling (`>=`), so a total exactly equal to the ceiling counts as a breach — the boundary the caller must
* stop on. An omitted ceiling never breaches. Pure and deterministic.
*/
export function evaluateAttemptBudget(
totals: AttemptMeterTotals,
budget: AttemptBudget,
): AttemptMeterVerdict {
const breaches: AttemptBudgetAxis[] = [];
if (budget.maxTokens !== undefined && totals.tokens >= budget.maxTokens) breaches.push("tokens");
if (budget.maxTurns !== undefined && totals.turns >= budget.maxTurns) breaches.push("turns");
if (budget.maxWallClockMs !== undefined && totals.wallClockMs >= budget.maxWallClockMs) {
breaches.push("wallClockMs");
}
if (budget.maxCostUsd !== undefined && totals.costUsd >= budget.maxCostUsd) breaches.push("costUsd");
return { totals, withinBudget: breaches.length === 0, breaches };
}
84 changes: 84 additions & 0 deletions packages/gittensory-engine/test/attempt-metering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
accumulateAttemptUsage,
meterAttemptUsage,
evaluateAttemptBudget,
type AttemptUsage,
} from "../dist/index.js";

const u = (tokens: number, turns: number, wallClockMs: number, costUsd: number): AttemptUsage => ({
tokens,
turns,
wallClockMs,
costUsd,
});

test("accumulateAttemptUsage folds one increment into a running total without mutating inputs", () => {
const total = u(100, 1, 500, 0.5);
const result = accumulateAttemptUsage(total, u(50, 1, 250, 0.25));
assert.deepEqual(result, u(150, 2, 750, 0.75)); // binary-exact cost values
assert.deepEqual(total, u(100, 1, 500, 0.5)); // input unchanged
});

test("meterAttemptUsage sums a sequence of increments from zero", () => {
const totals = meterAttemptUsage([u(100, 1, 500, 0.5), u(50, 1, 250, 0.25), u(25, 1, 100, 0.125)]);
assert.deepEqual(totals, u(175, 3, 850, 0.875));
});

test("meterAttemptUsage of an empty sequence is all-zero", () => {
assert.deepEqual(meterAttemptUsage([]), u(0, 0, 0, 0));
});

test("evaluateAttemptBudget: totals under every ceiling are within budget", () => {
const v = evaluateAttemptBudget(u(100, 2, 500, 0.05), {
maxTokens: 1000,
maxTurns: 10,
maxWallClockMs: 60000,
maxCostUsd: 1,
});
assert.equal(v.withinBudget, true);
assert.deepEqual(v.breaches, []);
});

test("evaluateAttemptBudget: a total exactly at a ceiling is a breach (>= boundary)", () => {
const v = evaluateAttemptBudget(u(1000, 0, 0, 0), { maxTokens: 1000 });
assert.equal(v.withinBudget, false);
assert.deepEqual(v.breaches, ["tokens"]);
});

test("evaluateAttemptBudget: a total just under the ceiling is within budget", () => {
const v = evaluateAttemptBudget(u(999, 0, 0, 0), { maxTokens: 1000 });
assert.equal(v.withinBudget, true);
assert.deepEqual(v.breaches, []);
});

test("evaluateAttemptBudget: each axis breaches independently", () => {
assert.deepEqual(evaluateAttemptBudget(u(0, 5, 0, 0), { maxTurns: 5 }).breaches, ["turns"]);
assert.deepEqual(evaluateAttemptBudget(u(0, 0, 60000, 0), { maxWallClockMs: 60000 }).breaches, ["wallClockMs"]);
assert.deepEqual(evaluateAttemptBudget(u(0, 0, 0, 2), { maxCostUsd: 1.5 }).breaches, ["costUsd"]);
});

test("evaluateAttemptBudget: multiple axes over ceiling all surface, in order", () => {
const v = evaluateAttemptBudget(u(2000, 20, 0, 0), { maxTokens: 1000, maxTurns: 10 });
assert.equal(v.withinBudget, false);
assert.deepEqual(v.breaches, ["tokens", "turns"]);
});

test("evaluateAttemptBudget: an omitted ceiling never breaches, even at huge totals", () => {
const v = evaluateAttemptBudget(u(1e9, 1e6, 1e9, 1e6), {});
assert.equal(v.withinBudget, true);
assert.deepEqual(v.breaches, []);
assert.equal(v.totals.tokens, 1e9); // verdict echoes the totals
});

test("evaluateAttemptBudget: a breach mid-attempt is detectable from accumulated totals", () => {
const budget = { maxTurns: 3 };
const steps = [u(10, 1, 100, 0), u(10, 1, 100, 0), u(10, 1, 100, 0)];
let total = meterAttemptUsage([]);
const withinAfterEachStep = steps.map((s) => {
total = accumulateAttemptUsage(total, s);
return evaluateAttemptBudget(total, budget).withinBudget;
});
assert.deepEqual(withinAfterEachStep, [true, true, false]); // breach at the 3rd turn
});