Skip to content

Commit 9e6fa94

Browse files
authored
Merge pull request #37 from Floe-Labs/latency-budget
feat: LatencyBudget — cumulative tool-chain deadline sibling to Budge…
2 parents af8019a + edda71c commit 9e6fa94

12 files changed

Lines changed: 611 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ both packages adhere to [Semantic Versioning](https://semver.org/).
1010

1111
## Unreleased
1212

13+
## py 0.5.0 / js 0.4.0 — 2026-07-16
14+
15+
### Added (py + js)
16+
17+
- **`LatencyBudget`** — BudgetGuard's sibling for time: tracks cumulative
18+
elapsed time across an agentic tool chain against an end-user SLA.
19+
`check(expected_ms)` raises the new `DeadlineExceeded` before a call whose
20+
projected duration would blow the SLA; `remaining_ms` is the readable
21+
mid-chain signal for router fallback/truncation; `advisory()` returns
22+
`near_deadline` / `used_bps` / `remaining_ms`, symmetric to the budget
23+
advisory's `near_limit`. Monotonic clock (`time.monotonic` /
24+
`performance.now`); cooperative by design — the guard supplies the deadline
25+
signal, killing a stalled in-flight call remains the framework's job.
26+
1327
## py 0.4.0 — 2026-07-15
1428

1529
### Added (py)

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,41 @@ cost_usd, label?, reserved?}` — identical to the TS package's `exportLog()`, s
175175
every agent produces the same shape regardless of stack and the streams can be
176176
concatenated and analysed together.
177177

178+
## LatencyBudget — deadlines, the same way
179+
180+
Money isn't the only budget an agent burns. `LatencyBudget` is `BudgetGuard`'s
181+
sibling for **time**: it tracks cumulative elapsed time across a tool chain
182+
against an end-user SLA and stops the *next* call before it would blow it.
183+
184+
```python
185+
from floe_guard import LatencyBudget, DeadlineExceeded
186+
187+
deadline = LatencyBudget(sla_ms=5000) # the user is promised 5s
188+
189+
for step in plan:
190+
deadline.check(expected_ms=step.est_ms) # raises DeadlineExceeded when projected over
191+
model = DEFAULT_MODEL
192+
if deadline.advisory().near_deadline: # 80% consumed by default —
193+
model = FAST_FALLBACK # downshift BEFORE the wall
194+
run(step, model, timeout_ms=deadline.remaining_ms)
195+
```
196+
197+
Same shape in TypeScript: `new LatencyBudget(5000)`, `check(expectedMs)`,
198+
`remainingMs`, `advisory().nearDeadline`.
199+
200+
Honest scope, mirroring the rest of this package:
201+
202+
- **Monotonic clock** (`time.monotonic()` / `performance.now()`) — NTP steps
203+
and DST can't corrupt the budget.
204+
- **Cooperative, not preemptive.** The guard supplies the deadline *signal*;
205+
killing an already-running stalled call is your framework's job (asyncio
206+
cancellation, `AbortSignal`). `check()` prevents the next call from starting.
207+
- **Advisory symmetry.** `near_deadline` / `used_bps` / `remaining_ms` are the
208+
latency twin of the budget advisory's `near_limit` / `used_bps` /
209+
`remaining_usd` — taper logic written against one ports to the other.
210+
- **In-process.** One instance per request/run; distributed/server-side latency
211+
tracking is out of scope.
212+
178213
## Request-sized estimates and mid-stream enforcement
179214

180215
Two gaps in last-cost prediction, closed in 0.4.0 (Python):

js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "floe-guard",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"description": "A local budget guardrail for AI agents — hard-stops your agent before its next LLM call crosses a USD ceiling. Vercel AI SDK middleware.",
55
"type": "module",
66
"license": "MIT",

js/src/errors.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,37 @@ export class UnpriceableModelError extends FloeGuardError {
5757
this.model = model;
5858
}
5959
}
60+
61+
/**
62+
* Thrown before a call whose projected duration would blow the SLA.
63+
*
64+
* The latency twin of {@link BudgetExceeded}: `LatencyBudget.check()` throws this
65+
* *instead of* letting the next tool/model call start, so the chain sheds work or
66+
* falls back to a faster path rather than violating the end-user SLA. Cooperative —
67+
* killing an already-running stalled call is the framework's job (AbortSignal),
68+
* not the guard's.
69+
*
70+
* Mirrors `DeadlineExceeded` in `src/floe_guard/errors.py` — message format is
71+
* kept byte-for-byte identical so both adapters read the same.
72+
*/
73+
/** Shared millisecond rounding for cross-language message parity: `toFixed(0)`
74+
* rounds half-up while Python's `:.0f` rounds half-to-even, so tie values
75+
* would break the byte-for-byte contract. Both packages use floor(x + 0.5)
76+
* (see `_round_half_up` in `src/floe_guard/errors.py`). */
77+
function roundHalfUp(ms: number): number {
78+
return Math.floor(ms + 0.5);
79+
}
80+
81+
export class DeadlineExceeded extends FloeGuardError {
82+
readonly elapsedMs: number;
83+
readonly slaMs: number;
84+
85+
constructor(elapsedMs: number, slaMs: number) {
86+
super(
87+
`DEADLINE EXCEEDED — call blocked (elapsed ${roundHalfUp(elapsedMs)}ms of ${roundHalfUp(slaMs)}ms SLA)`,
88+
);
89+
this.name = "DeadlineExceeded";
90+
this.elapsedMs = elapsedMs;
91+
this.slaMs = slaMs;
92+
}
93+
}

js/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,15 @@ export {
1212
type BudgetAdvisory,
1313
type SpendEvent,
1414
} from "./guard.js";
15+
export {
16+
LatencyBudget,
17+
type LatencyBudgetOptions,
18+
type LatencyAdvisory,
19+
} from "./latency.js";
1520
export {
1621
FloeGuardError,
1722
BudgetExceeded,
23+
DeadlineExceeded,
1824
UnpriceableModelError,
1925
} from "./errors.js";
2026
export {

js/src/latency.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* LatencyBudget — a cumulative tool-chain deadline, sibling to BudgetGuard.
3+
*
4+
* BudgetGuard stops an agent before its next call crosses a USD ceiling;
5+
* LatencyBudget stops it before the next call would blow an end-user SLA:
6+
*
7+
* ```ts
8+
* const deadline = new LatencyBudget(5000);
9+
* ...
10+
* deadline.check(800); // throws DeadlineExceeded when projected over
11+
* if (deadline.advisory().nearDeadline) useFasterModel();
12+
* router.pick({ maxLatencyMs: deadline.remainingMs });
13+
* ```
14+
*
15+
* Design notes (mirroring `src/floe_guard/latency.py`):
16+
* - **Monotonic clock** — `performance.now()`, never wall time.
17+
* - **Cooperative, not preemptive** — the guard provides the deadline signal;
18+
* aborting a stalled in-flight call is the framework's job (AbortSignal).
19+
* `check()` prevents the NEXT call from starting.
20+
* - **Advisory symmetry** — `nearDeadline` / `usedBps` / `remainingMs` are the
21+
* latency twin of BudgetGuard's `nearLimit` / `usedBps` / `remainingUsd`.
22+
* - **In-process scope** — one instance per request/run; distributed latency
23+
* tracking is out of scope.
24+
*/
25+
26+
import { DeadlineExceeded } from "./errors.js";
27+
28+
/** A context-aware deadline signal — the latency twin of {@link BudgetAdvisory}.
29+
* Soft by design; the hard-stop is {@link LatencyBudget.check}. */
30+
export interface LatencyAdvisory {
31+
nearDeadline: boolean;
32+
/** SLA consumed, basis points 0..10000 (8500 = 85%). */
33+
usedBps: number;
34+
remainingMs: number;
35+
slaMs: number;
36+
elapsedMs: number;
37+
}
38+
39+
export interface LatencyBudgetOptions {
40+
/**
41+
* Utilization (basis points, 0..10000) at which {@link LatencyBudget.advisory}
42+
* flags `nearDeadline` so an agent can downshift to a faster path before the
43+
* wall. Default 8000 (80%), matching BudgetGuard's `nearLimitBps`.
44+
*/
45+
nearDeadlineBps?: number;
46+
/** Invoked with `(elapsedMs, slaMs)` right before {@link DeadlineExceeded} is thrown. */
47+
onBlock?: (elapsedMs: number, slaMs: number) => void;
48+
/** Milliseconds-returning monotonic clock, injectable for tests. Defaults to `performance.now`. */
49+
clock?: () => number;
50+
}
51+
52+
export class LatencyBudget {
53+
readonly slaMs: number;
54+
readonly nearDeadlineBps: number;
55+
private readonly onBlock?: (elapsedMs: number, slaMs: number) => void;
56+
private readonly clock: () => number;
57+
private readonly startedAt: number;
58+
59+
/** The budget starts counting at construction — build it when the request
60+
* (and its SLA) starts. */
61+
constructor(slaMs: number, options: LatencyBudgetOptions = {}) {
62+
if (!(Number.isFinite(slaMs) && slaMs > 0)) {
63+
throw new RangeError("slaMs must be > 0");
64+
}
65+
const nearDeadlineBps = options.nearDeadlineBps ?? 8000;
66+
if (!Number.isInteger(nearDeadlineBps) || nearDeadlineBps < 0 || nearDeadlineBps > 10000) {
67+
throw new RangeError("nearDeadlineBps must be an integer 0..10000");
68+
}
69+
this.slaMs = slaMs;
70+
this.nearDeadlineBps = nearDeadlineBps;
71+
this.onBlock = options.onBlock;
72+
this.clock = options.clock ?? (() => performance.now());
73+
this.startedAt = this.clock();
74+
}
75+
76+
/** Milliseconds since construction (monotonic). */
77+
get elapsedMs(): number {
78+
return this.clock() - this.startedAt;
79+
}
80+
81+
/** Milliseconds left before the SLA, floored at 0 — the readable signal a
82+
* router uses to pick a faster fallback or truncate work mid-chain. */
83+
get remainingMs(): number {
84+
return Math.max(0, this.slaMs - this.elapsedMs);
85+
}
86+
87+
/**
88+
* Throw {@link DeadlineExceeded} when the projected elapsed time (now +
89+
* `expectedMs` for the upcoming call) would blow the SLA. Call it
90+
* immediately before each tool/model call; pass 0 to only gate on time
91+
* already spent.
92+
*/
93+
check(expectedMs = 0): void {
94+
if (!(Number.isFinite(expectedMs) && expectedMs >= 0)) {
95+
throw new RangeError("expectedMs must be >= 0");
96+
}
97+
const elapsed = this.elapsedMs;
98+
if (elapsed + expectedMs > this.slaMs) {
99+
this.onBlock?.(elapsed, this.slaMs);
100+
throw new DeadlineExceeded(elapsed, this.slaMs);
101+
}
102+
}
103+
104+
/** The soft near-deadline signal — symmetric to `BudgetGuard.advisory()`. */
105+
advisory(): LatencyAdvisory {
106+
const elapsed = this.elapsedMs;
107+
// round (not floor) — matches the Python twin; float clock arithmetic can
108+
// land a hair under an exact boundary.
109+
const usedBps = elapsed > 0 ? Math.min(10000, Math.round((elapsed * 10000) / this.slaMs)) : 0;
110+
return {
111+
nearDeadline: usedBps >= this.nearDeadlineBps,
112+
usedBps,
113+
remainingMs: Math.max(0, this.slaMs - elapsed),
114+
slaMs: this.slaMs,
115+
elapsedMs: elapsed,
116+
};
117+
}
118+
}

js/test/latency.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* LatencyBudget — cumulative tool-chain deadline (FLO-624).
3+
*
4+
* Mirrors tests/test_latency.py; an injectable fake clock keeps every test
5+
* sleep-free.
6+
*/
7+
8+
import { describe, it, expect } from "vitest";
9+
import { LatencyBudget, DeadlineExceeded } from "../src/index.js";
10+
11+
function make(slaMs = 5000, options: Record<string, unknown> = {}) {
12+
let now = 100_000; // arbitrary origin — only deltas matter
13+
const clock = () => now;
14+
const advanceMs = (ms: number) => {
15+
now += ms;
16+
};
17+
const budget = new LatencyBudget(slaMs, { clock, ...options });
18+
return { budget, advanceMs };
19+
}
20+
21+
describe("LatencyBudget", () => {
22+
it("check passes with headroom and blocks when projected over", () => {
23+
const { budget, advanceMs } = make(5000);
24+
advanceMs(3000);
25+
expect(() => budget.check(1000)).not.toThrow(); // 3000 + 1000 <= 5000
26+
27+
expect(() => budget.check(2500)).toThrow(DeadlineExceeded); // projected over
28+
try {
29+
budget.check(2500);
30+
} catch (e) {
31+
expect((e as DeadlineExceeded).slaMs).toBe(5000);
32+
expect((e as DeadlineExceeded).elapsedMs).toBeCloseTo(3000);
33+
}
34+
});
35+
36+
it("check without an estimate gates on elapsed only", () => {
37+
const { budget, advanceMs } = make(1000);
38+
advanceMs(999);
39+
expect(() => budget.check()).not.toThrow();
40+
advanceMs(2);
41+
expect(() => budget.check()).toThrow(DeadlineExceeded);
42+
});
43+
44+
it("remainingMs is readable mid-chain and floors at zero", () => {
45+
const { budget, advanceMs } = make(5000);
46+
advanceMs(1500);
47+
expect(budget.remainingMs).toBeCloseTo(3500);
48+
advanceMs(9000);
49+
expect(budget.remainingMs).toBe(0);
50+
});
51+
52+
it("advisory is symmetric to BudgetGuard's (nearDeadline at the 80% default)", () => {
53+
const { budget, advanceMs } = make(5000);
54+
advanceMs(2500);
55+
const mid = budget.advisory();
56+
expect(mid.usedBps).toBe(5000);
57+
expect(mid.nearDeadline).toBe(false);
58+
expect(mid.remainingMs).toBeCloseTo(2500);
59+
60+
advanceMs(1600); // 4100/5000 = 82%
61+
const late = budget.advisory();
62+
expect(late.usedBps).toBe(8200);
63+
expect(late.nearDeadline).toBe(true);
64+
65+
advanceMs(9000);
66+
expect(budget.advisory().usedBps).toBe(10000); // capped
67+
});
68+
69+
it("onBlock fires before the throw", () => {
70+
const calls: Array<[number, number]> = [];
71+
const { budget, advanceMs } = make(1000, { onBlock: (e: number, s: number) => calls.push([e, s]) });
72+
advanceMs(1500);
73+
expect(() => budget.check()).toThrow(DeadlineExceeded);
74+
expect(calls).toHaveLength(1);
75+
expect(calls[0]![1]).toBe(1000);
76+
});
77+
78+
it("validates constructor and check inputs", () => {
79+
expect(() => new LatencyBudget(0)).toThrow(RangeError);
80+
expect(() => new LatencyBudget(5000, { nearDeadlineBps: 20000 })).toThrow(RangeError);
81+
const { budget } = make();
82+
expect(() => budget.check(-1)).toThrow(RangeError);
83+
});
84+
85+
it("rejects non-finite inputs (inf would disable the deadline; NaN slips comparisons)", () => {
86+
expect(() => new LatencyBudget(Infinity)).toThrow(RangeError);
87+
expect(() => new LatencyBudget(NaN)).toThrow(RangeError);
88+
const { budget } = make();
89+
expect(() => budget.check(NaN)).toThrow(RangeError);
90+
expect(() => budget.check(Infinity)).toThrow(RangeError);
91+
});
92+
93+
it("deadline message uses the shared half-up rounding (byte-parity with Python)", () => {
94+
expect(new DeadlineExceeded(0.5, 5000.5).message).toBe(
95+
"DEADLINE EXCEEDED — call blocked (elapsed 1ms of 5001ms SLA)",
96+
);
97+
expect(new DeadlineExceeded(-0.5, 1000).message).toBe(
98+
"DEADLINE EXCEEDED — call blocked (elapsed 0ms of 1000ms SLA)",
99+
);
100+
});
101+
});

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "floe-guard"
7-
version = "0.4.0"
7+
version = "0.5.0"
88
description = "Local budget guardrail for AI agents — hard-stops a runaway loop before its next LLM call crosses a spend ceiling. No account, no network."
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/floe_guard/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,30 @@
1515

1616
from .errors import (
1717
BudgetExceeded,
18+
DeadlineExceeded,
1819
FloeGuardError,
1920
HostedEnforcementError,
2021
UnpriceableModelError,
2122
UnpriceableModelWarning,
2223
)
2324
from .guard import BudgetAdvisory, BudgetGuard, SpendEvent
2425
from .hosted import hosted_enforcement_available, hosted_remaining_usd
26+
from .latency import LatencyAdvisory, LatencyBudget
2527
from .pricing import ManualPrice, PricedModel, price_tokens, resolve_price
2628
from .stream import StreamGuard, guard_stream
2729

28-
__version__ = "0.4.0" # keep in lockstep with pyproject.toml
30+
__version__ = "0.5.0" # keep in lockstep with pyproject.toml
2931

3032
__all__ = [
3133
"BudgetGuard",
3234
"BudgetAdvisory",
3335
"SpendEvent",
36+
"LatencyBudget",
37+
"LatencyAdvisory",
3438
"StreamGuard",
3539
"guard_stream",
3640
"BudgetExceeded",
41+
"DeadlineExceeded",
3742
"FloeGuardError",
3843
"HostedEnforcementError",
3944
"UnpriceableModelError",

0 commit comments

Comments
 (0)