Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/budget-excludes-cache-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

The session input-token limit now counts only new input (uncached input plus cache writes). Cache reads no longer count toward the limit, the grant after Continue, or the quota shared with subagents, so long cache-heavy sessions stop hitting continuation prompts for replaying their own history. Usage telemetry still reports raw input and cache totals. Providers that report no cache details count exactly as before.
6 changes: 5 additions & 1 deletion docs/agent-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ timeout. Session expiration follows [run-data retention](#run-data-retention);
a zero-retention session loses its stored payloads when it ends.

Input tokens, output tokens, and model token cost are checked independently.
The input limit counts only new input: uncached input plus cache writes. Cache
reads, the history a provider replays from its prompt cache on every step, do
not count, so long tool-heavy sessions are not stopped for re-reading their own
context. When a provider reports no cache details, all input counts.
The model call that crosses a limit is allowed to finish because exact usage
arrives after the call completes. Before the next model call, eve pauses the
turn (`turn.waiting`) and sends a deterministic continuation prompt with two options:
Expand Down Expand Up @@ -296,7 +300,7 @@ no inherited quota also fails instead of raising a continuation prompt that
could only grant another zero-value window.

When `maxInputTokensPerSession` is omitted, root sessions apply a default
input budget of `40_000_000` provider-reported input tokens.
input budget of `40_000_000` new input tokens.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs now say the default is 40M new input tokens, but the authored API contract still says the opposite. The maxInputTokensPerSession JSDoc in packages/eve/src/shared/agent-definition.ts:188 (what authors see on hover in agent.ts) still reads "Maximum provider-reported input tokens accumulated by one durable session", and SessionLimits.maxInputTokensPerSession in packages/eve/src/harness/types.ts:121 says the same. After this PR the limit is inputTokens - cacheReadTokens (getSessionBudgetUsage), so both comments describe the wrong quantity. Can we update them to match: new input (uncached plus cache writes), with cache reads excluded, and full input counted when the provider reports no cache details?

`maxOutputTokensPerSession` and `maxTokenCostUsdPerSession` are unset by
default. `maxTokenCostUsdPerSession` is a US-dollar limit on model token cost,
not tool or infrastructure spend. It uses the cost reported with each model
Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/harness/hitl/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function checkSessionUsageLimit(input: {
usedCostUsd: violation.usedCostUsd,
}
: {
cacheReadTokens: usage.cacheReadTokens,
inputTokens: usage.inputTokens,
kind: violation.kind,
limit: violation.limit,
Expand Down
6 changes: 4 additions & 2 deletions packages/eve/src/harness/tool-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1921,8 +1921,10 @@ describe("createToolLoopHarness", () => {

it.each([
{
// The limit counts new input (14 minus 2 cache reads); details keep the raw totals.
details: {
inputTokens: 12,
cacheReadTokens: 2,
inputTokens: 14,
kind: "input",
limit: 12,
outputTokens: 3,
Expand All @@ -1936,7 +1938,7 @@ describe("createToolLoopHarness", () => {
cacheReadTokens: 2,
cacheWriteTokens: 1,
costUsd: 0,
inputTokens: 12,
inputTokens: 14,
outputTokens: 3,
sawCost: false,
},
Expand Down
54 changes: 54 additions & 0 deletions packages/eve/src/harness/turn-tag-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";

import { extractTokenUsageDelta } from "#harness/model-call/usage.js";
import type { HarnessStepResult } from "#harness/step-hooks.js";
import {
accumulateSessionUsage,
accumulateTurnUsage,
addTurnUsage,
bumpSessionRuntimeUsageLimits,
getSessionRemainingUsageQuota,
getSessionRuntimeUsageLimits,
Expand Down Expand Up @@ -429,6 +432,57 @@ describe("session token limits", () => {
outputTokens: false,
});
});

describe("counts only new input", () => {
// One model step that replayed 90 cached tokens, wrote 4 to the cache and sent 6 uncached.
function sessionAfterStep(usage: Partial<NonNullable<HarnessStepResult["usage"]>>) {
return {
...addTurnUsage(
makeSession(),
"turn_0",
extractTokenUsageDelta({
costUsd: undefined,
usage: usage as NonNullable<HarnessStepResult["usage"]>,
}),
),
limits: { maxInputTokensPerSession: 30 },
};
}

it("excludes cache reads from the limit, the grant and the remaining quota", () => {
const session = sessionAfterStep({
inputTokens: 100,
inputTokenDetails: { cacheReadTokens: 90, cacheWriteTokens: 4, noCacheTokens: 6 },
outputTokens: 1,
});

// Telemetry keeps the raw totals.
expect(getSessionTokenUsage(session)).toMatchObject({
cacheReadTokens: 90,
cacheWriteTokens: 4,
inputTokens: 100,
});
expect(getSessionUsageLimitViolation(session)).toBeNull();
expect(getSessionRemainingUsageQuota(session)).toMatchObject({ inputTokens: 20 });
expect(getSessionRuntimeUsageLimits(bumpSessionRuntimeUsageLimits(session))).toEqual({
inputTokens: 40,
});
});

it("counts the full input when the provider reports no cache details", () => {
const session = sessionAfterStep({ inputTokens: 100, outputTokens: 1 });

expect(getSessionUsageLimitViolation(session)).toEqual({
kind: "input",
limit: 30,
usedTokens: 100,
});
expect(getSessionRemainingUsageQuota(session)).toMatchObject({ inputTokens: 0 });
expect(getSessionRuntimeUsageLimits(bumpSessionRuntimeUsageLimits(session))).toEqual({
inputTokens: 130,
});
});
});
});

describe("accumulateSessionUsage", () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/eve/src/harness/turn-tag-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,14 @@ export function getSessionRuntimeUsageLimits(

/**
* Bumps the runtime usage limits after the user grants a continuation. Each
* capped axis is re-anchored to `current usage + configured limit`, so one
* capped axis is re-anchored to `current budget usage + configured limit`, so one
* approval buys one full configured window even after an overshoot. All axes
* bump together to avoid back-to-back prompts. Configured limits never change.
*/
export function bumpSessionRuntimeUsageLimits(session: HarnessSession): HarnessSession {
const usage = getSessionTokenUsage(session);
const bumped = grantRuntimeUsageLimits({
configured: configuredSessionUsageLimits(session),
usage,
usage: getSessionBudgetUsage(session),
});
return {
...session,
Expand All @@ -144,23 +143,31 @@ export function getSessionRemainingUsageQuota(session: Pick<HarnessSession, "lim
inputTokens: number | false;
outputTokens: number | false;
} {
const usage = getSessionTokenUsage(session);
const runtime = getSessionRuntimeUsageLimits(session);
return remainingRuntimeUsageQuota({ runtime, usage });
return remainingRuntimeUsageQuota({ runtime, usage: getSessionBudgetUsage(session) });
}

export function getSessionUsageLimitViolation(
session: Pick<HarnessSession, "limits" | "state">,
): SessionUsageLimitViolation | null {
const usage = getSessionTokenUsage(session);
const runtime = getSessionRuntimeUsageLimits(session);
return findRuntimeUsageLimitViolation({
configured: configuredSessionUsageLimits(session),
runtime,
usage,
usage: getSessionBudgetUsage(session),
});
}

/**
* The session's usage as the limits count it. Cache reads are replayed history, not new work,
* so the input axis counts only new input: uncached input plus cache writes. A provider that
* reports no cache details counts its full input, as before.
*/
function getSessionBudgetUsage(session: Pick<HarnessSession, "state">): TokenUsageTotals {
const usage = getSessionTokenUsage(session);
return { ...usage, inputTokens: Math.max(0, usage.inputTokens - usage.cacheReadTokens) };
}

function configuredSessionUsageLimits(
session: Pick<HarnessSession, "limits">,
): SessionRuntimeUsageLimits {
Expand Down
Loading