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
28 changes: 26 additions & 2 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,20 @@ export async function runTurn(
): Promise<void> {
const backend = await resolveBackend(ctx);
if (backend === "local") {
// The signal was dropped here, so the REPL's Ctrl+C controller could not
// reach a local turn at all — the abort fired and nothing observed it.
// Aether meters nothing on a local brain, so the session is unmetered
// rather than "zero spend so far".
getRegistry().markLocalUnmetered();
// The signal used to be dropped here, so the REPL Ctrl+C controller could
// not reach a local turn at all: the abort fired and nothing observed it.
await runLocalTurn(ctx, prompt, signal);
return;
}
await runCloudTurn(ctx, prompt, signal, onFrame, onPulsePaint);
}

/** Monotonic per-process turn id, so a settled turn can be recognised on replay. */
let cloudTurnCounter = 0;

/** The cloud path — build an envelope, POST to the universal stream, render.
* Extracted so runTurn can fork local vs cloud. */
async function runCloudTurn(
Expand All @@ -125,6 +131,20 @@ async function runCloudTurn(
onFrame?: (f: StreamFrame) => void,
onPulsePaint?: () => void,
): Promise<void> {
const reg = getRegistry();
// The operator's session cap is checked BEFORE a billable turn starts. It is
// a local circuit breaker, not a billing control: it stops this terminal
// from starting more work, and changes nothing about the account.
const cap = reg.checkUvtCap();
if (cap.capped) {
throw new ChatTurnError(
`session UVT cap reached — ${cap.observed} of ${cap.cap} observed. ` +
"No further turns will start. This is a local stop only; your plan and " +
"balance are unchanged. Raise it with /limit <amount>, or /limit off.",
);
}
const turnId = `turn-${++cloudTurnCounter}`;
reg.beginTurn(turnId);
const req = buildChatRequest({
prompt,
model: ctx.flags.model ?? ctx.cfg.defaultModel,
Expand Down Expand Up @@ -167,6 +187,10 @@ async function runCloudTurn(
if (frame.type !== "open" && frame.type !== "ping") pulse.stop();
// The server signs each turn and returns it; persist the signed receipt
// locally (best-effort, never breaks the chat).
// The terminal frame carries the turn's authoritative cost. Settled by
// turn id so a reconnect replaying it cannot count the same turn twice,
// and only from the server's own number — never estimated from tokens.
if (frame.type === "done") getRegistry().settleTurn(turnId, frame.uvt);
if (frame.type === "custody") appendCustody(frame.custody);
if (frame.type === "error") sawError = frame.msg;
if (frame.type === "error" || frame.type === "done") sawTerminal = true;
Expand Down
38 changes: 31 additions & 7 deletions src/commands/slash_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,28 @@ export async function limitSlash(ctx: AppContext, out: Writable, arg: string): P

if (!arg.trim()) {
const current = registry.uvtCap;
const spent = registry.uvtSpent;
const status = registry.usageStatus();
const observed = registry.uvtObserved;
// "spent: 0" used to print whether the session had cost nothing or whether
// no usage frame had ever arrived. Those are different answers, and only
// one of them is a measurement.
const spentLabel =
status === "local-unmetered"
? "LOCAL — not metered by Aether"
: observed == null
? "unknown — the server has reported no usage yet"
: String(observed);
if (current == null) {
out.write("UVT cap: none (uncapped)\n");
out.write(`UVT cap: none (uncapped) observed: ${spentLabel}\n`);
} else if (observed == null || status !== "observed") {
out.write(`UVT cap: ${theme.bold(String(current))} observed: ${spentLabel}\n`);
out.write(theme.dim(" the cap cannot trip until the server reports usage.\n"));
} else {
const remaining = Math.max(0, current - spent);
const pct = current > 0 ? Math.round((spent / current) * 100) : 0;
const bar = renderUvtBar(pct, 20);
out.write(`UVT cap: ${theme.bold(String(current))} spent: ${spent} remaining: ${remaining} ${bar}\n`);
const remaining = Math.max(0, current - observed);
const pct = current > 0 ? Math.round((observed / current) * 100) : 0;
out.write(
`UVT cap: ${theme.bold(String(current))} observed: ${observed} remaining: ${remaining} ${renderUvtBar(pct, 20)}\n`,
);
}
out.write(theme.dim(" /limit <amount> set cap (e.g., /limit 50000)\n"));
out.write(theme.dim(" /limit off remove cap\n"));
Expand All @@ -197,7 +211,17 @@ export async function limitSlash(ctx: AppContext, out: Writable, arg: string): P
}

registry.setUvtCap(Math.floor(n));
out.write(`${theme.cyan("⚡ UVT cap set")} ${theme.bold(String(Math.floor(n)))} — agent will pause and ask permission if ceiling hit\n`);
// The old wording promised the agent would "pause and ask permission".
// Nothing enforced the cap at all, so that was never true. State what now
// actually happens, and be explicit that this is not a billing control.
out.write(`${theme.cyan("⚡ UVT cap set")} ${theme.bold(String(Math.floor(n)))}\n`);
out.write(
theme.dim(
" no further turn will START once the server-reported spend reaches it.\n" +
" a turn already in flight may still complete and be billed.\n" +
" this is a local stop only — your plan and balance are unchanged.\n",
),
);
syncAfter(ctx);
}

Expand Down
96 changes: 89 additions & 7 deletions src/core/context_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,32 @@ export class ContextRegistry {
pins: PinnedEntry[] = [];
drops: string[] = [];
uvtCap: number | null = null;
uvtSpent = 0;

/**
* Session UVT actually reported by the server. null means NO authoritative
* frame has been seen — which is not the same as zero, and must never be
* rendered as it. Only the server knows what a turn cost; nothing here
* estimates it from token counts.
*/
uvtObserved: number | null = null;

/** Turn ids already settled, so a replayed terminal frame cannot double-count. */
private readonly settledTurns = new Set<string>();

/** True once this session is known to run on a local, un-metered brain. */
private localUnmetered = false;

/**
* Back-compat accessor for the HUD, which needs a number. It reports 0 when
* usage is UNKNOWN, so anything that must tell those apart has to read
* uvtObserved instead.
*/
get uvtSpent(): number {
return this.uvtObserved ?? 0;
}
set uvtSpent(value: number) {
this.uvtObserved = value;
}
planPath: string | null = null;
sessionLabel = "untitled";

Expand Down Expand Up @@ -97,6 +122,33 @@ export class ContextRegistry {
this.uvtCap = amount;
}

/** Mark a turn as in flight. Idempotent. */
beginTurn(turnId: string): void {
this.settledTurns.delete(turnId);
}

/**
* Record a turn's authoritative cost, once. The terminal frame carries the
* turn total, and a reconnect can replay it, so settling is keyed by turn id
* rather than accumulated blindly.
*/
settleTurn(turnId: string, uvt: number): void {
if (this.settledTurns.has(turnId)) return;
if (!Number.isFinite(uvt) || uvt < 0) return;
this.settledTurns.add(turnId);
this.uvtObserved = (this.uvtObserved ?? 0) + uvt;
}

/** This session runs on a local brain, so Aether meters nothing. */
markLocalUnmetered(): void {
this.localUnmetered = true;
}

usageStatus(): "unknown" | "observed" | "local-unmetered" {
if (this.localUnmetered) return "local-unmetered";
return this.uvtObserved == null ? "unknown" : "observed";
}

/** Track a temporary file so /purge can clean it up. */
tempFiles: string[] = [];

Expand All @@ -113,7 +165,8 @@ export class ContextRegistry {
this.pins = [];
this.drops = [];
this.uvtCap = null;
this.uvtSpent = 0;
this.uvtObserved = null;
this.settledTurns.clear();

let removedFiles = 0;
for (const f of this.tempFiles) {
Expand All @@ -124,11 +177,40 @@ export class ContextRegistry {
return { clearedPins, removedFiles };
}

/** Check if UVT cap is exceeded. Returns remaining or -1 if exceeded. */
checkUvtCap(): { capped: boolean; remaining: number; cap: number | null } {
if (this.uvtCap == null) return { capped: false, remaining: Infinity, cap: null };
const remaining = this.uvtCap - this.uvtSpent;
return { capped: remaining <= 0, remaining: Math.max(0, remaining), cap: this.uvtCap };
/**
* Is the operator's session cap reached?
*
* This is a local circuit breaker, not a billing ledger. The server remains
* the billing authority; tripping this stops the terminal from starting
* another billable turn, and changes nothing about the account.
*
* Two states deliberately do NOT trip it: an unmetered local session (there
* is no Aether spend to cap) and a session where no authoritative usage has
* been seen (there is no evidence the cap was reached, and guessing would
* either block work that cost nothing or wave through work that cost a lot).
*/
checkUvtCap(): {
capped: boolean;
/** null when there is no measured spend to subtract — NOT the full cap. */
remaining: number | null;
cap: number | null;
observed: number | null;
status: "unknown" | "observed" | "local-unmetered";
} {
const status = this.usageStatus();
const observed = this.uvtObserved;
// Unknown spend yields a null headroom, never the whole cap. Reporting the
// full cap as remaining is the same false zero in a different costume: it
// tells the user they have their entire budget left when the truth is that
// nobody has measured any of it.
if (status !== "observed" || observed == null) {
return { capped: false, remaining: null, cap: this.uvtCap, observed, status };
}
if (this.uvtCap == null) {
return { capped: false, remaining: null, cap: null, observed, status };
}
const remaining = this.uvtCap - observed;
return { capped: remaining <= 0, remaining: Math.max(0, remaining), cap: this.uvtCap, observed, status };
}

// ── HUD methods ──
Expand Down
104 changes: 104 additions & 0 deletions test/usage_cap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// /limit shipped as a control that enforced nothing.
//
// `uvtSpent` was only ever written as `= 0` (construction, purge, snapshot
// restore) — no usage frame ever incremented it — and `checkUvtCap()` had zero
// callers. So the cap never stopped anything, and the readout reported
// "spent: 0" no matter what the session had actually cost.
//
// These pin the two properties that matter: an unobserved session is UNKNOWN
// rather than zero, and a reached cap actually refuses the next billable turn.

import { test } from "node:test";
import assert from "node:assert/strict";
import { ContextRegistry } from "../src/core/context_registry.js";

test("a session with no authoritative usage frame reports unknown, not zero", () => {
const reg = new ContextRegistry();
assert.equal(reg.uvtObserved, null, "no frame seen means no number to report");
assert.notEqual(reg.uvtObserved, 0, "zero is a measurement; this is the absence of one");
});

test("a settled turn is counted once, and a duplicate done frame does not double it", () => {
const reg = new ContextRegistry();
reg.beginTurn("turn-1");
reg.settleTurn("turn-1", 1200);
reg.settleTurn("turn-1", 1200); // replay after reconnect
assert.equal(reg.uvtObserved, 1200);
});

test("distinct turns accumulate", () => {
const reg = new ContextRegistry();
reg.beginTurn("t1");
reg.settleTurn("t1", 1000);
reg.beginTurn("t2");
reg.settleTurn("t2", 500);
assert.equal(reg.uvtObserved, 1500);
});

test("an unknown session is never reported as capped", () => {
const reg = new ContextRegistry();
reg.setUvtCap(1000);
const check = reg.checkUvtCap();
assert.equal(check.capped, false, "with nothing observed there is no evidence the cap was reached");
assert.equal(check.observed, null);
});

test("the cap trips once observed spend reaches it", () => {
const reg = new ContextRegistry();
reg.setUvtCap(1000);
reg.beginTurn("t1");
reg.settleTurn("t1", 999);
assert.equal(reg.checkUvtCap().capped, false);
reg.beginTurn("t2");
reg.settleTurn("t2", 1);
assert.equal(reg.checkUvtCap().capped, true, "reaching the cap counts as reaching it");
assert.equal(reg.checkUvtCap().remaining, 0);
});

test("no cap means never capped, whatever was spent", () => {
const reg = new ContextRegistry();
reg.beginTurn("t1");
reg.settleTurn("t1", 10_000_000);
const check = reg.checkUvtCap();
assert.equal(check.capped, false);
assert.equal(check.cap, null);
});

test("local unmetered sessions are labelled, not counted as zero spend", () => {
const reg = new ContextRegistry();
reg.markLocalUnmetered();
assert.equal(reg.usageStatus(), "local-unmetered");
assert.equal(reg.checkUvtCap().capped, false, "an unmetered session cannot exceed an Aether cap");
});

test("usageStatus distinguishes unknown from observed", () => {
const reg = new ContextRegistry();
assert.equal(reg.usageStatus(), "unknown");
reg.beginTurn("t1");
reg.settleTurn("t1", 5);
assert.equal(reg.usageStatus(), "observed");
});

test("purge clears observed usage back to unknown, not to zero", () => {
const reg = new ContextRegistry();
reg.beginTurn("t1");
reg.settleTurn("t1", 5);
reg.purge();
assert.equal(reg.uvtObserved, null);
assert.equal(reg.usageStatus(), "unknown");
});

test("unmeasured headroom is null, never the full cap", () => {
// Reporting `remaining: cap` when nothing has been measured is the same false
// zero wearing a different hat — it tells the user their whole budget is
// intact when in fact none of it has been counted.
const reg = new ContextRegistry();
reg.setUvtCap(1000);
const unknown = reg.checkUvtCap();
assert.equal(unknown.remaining, null);
assert.notEqual(unknown.remaining, 1000, "the full cap is not a measurement of headroom");

reg.beginTurn("t1");
reg.settleTurn("t1", 400);
assert.equal(reg.checkUvtCap().remaining, 600, "once measured, headroom is real");
});