
Measuring Tokens-per-Task in JavaScript Agents with Per-Step Cost Tracing and Budget Limits
I have spent too many evenings staring at monthly token dashboards trying to answer a question they structurally cannot answer: which task burned the budget. Futurum Research's finding that agentic workloads can push token use per task up to 100 times didn't land as news to me. It landed as a name for something I had already been watching happen. This post is the instrumentation I built in response: per-step token accounting threaded through a JavaScript agent with AsyncLocalStorage, a JSONL cost trace, and hard budget limits that stop a runaway loop before it spends.
The 100x Claim, and Why a Monthly Usage Dashboard Can't Check It
Futurum Research, in a release carried by Business Wire on 2026-09-25, reports that agentic AI can raise token use per task by up to 100 times and is accelerating a shift away from per-token pricing. That is the report's claim, not my measurement, and I am not going to pretend I reproduced the full 100x across every workload. What I can say is that the mechanism is real and easy to see in a single run.
My position is blunt: aggregate monthly token counts are the wrong instrument for agents. A dashboard gives you the total and the daily shape. It cannot tell you which task, which step, or which retry ate the budget. When one runaway loop is 40% of a day's spend, a number with no step dimension is a bill, not a diagnostic.
Why Token Cost Multiplies Inside a JavaScript Agent Loop
The multiplier is not a metaphor. Every iteration of an agent loop resends the full conversation, not just the new message. Tool results get injected back into context, so the next request is larger than the last. Self-correction re-prompts after a failed attempt with the failure in context. Retries re-run a step whose earlier work already succeeded and was already billed.
Cost per task grows roughly with iterations times average context size, and the second term is not constant. A nine-step loop on a 6k-token context is not 9x one call's input. It is closer to the sum of a growing context: 6k + 7.5k + 9k + ... plus the final output tokens on every step. The math is closer to quadratic than linear in the number of steps.
The Four Multipliers I See in Practice
These are my observations from running agents, not the report's numbers:
- Iterations. Every loop step is a full round trip with the whole history attached, so step ten is the most expensive request of the run.
- Context growth from tool output. A fetched document, a JSON blob, or a DOM dump lands in context and stays there for the rest of the loop.
- Retry and self-correction loops. A model that fails schema validation gets re-prompted, often two or three times, with the same large context each attempt.
- Oversized tool results. A single tool response can dwarf every completion token in the run combined.
Defining Tokens-per-Task So It Can Actually Be Measured
Before writing any instrumentation, pin the unit. A task is one user intent. Tokens-per-task is the sum of prompt plus completion tokens across every model call made while serving that intent, including failed attempts that produced no usable output. If you exclude failures, you are measuring the happy path and lying to yourself.
The number that actually matters is cost-per-successful-task: total dollars spent on the task category divided by successful task completions. A cheap failed task still costs money, and at the margin it costs more than a cheap success because the user retries and you pay twice.
A Minimum Viable Per-Step Tracer in JavaScript
Threading a task ID through every function signature works until the fifth refactor. Node's AsyncLocalStorage (built on async_hooks) lets task context follow the promise chain automatically, so a tool deep in a call stack can read the current task without receiving it as an argument.
import { AsyncLocalStorage } from "node:async_hooks";
const taskStore = new AsyncLocalStorage();
export function currentTask() {
const store = taskStore.getStore();
if (!store) throw new Error("no task context: wrap work in runTask()");
return store;
}
export function runTask({ id, model, limits }, work) {
const task = {
id, model, limits,
stepIndex: 0,
promptTokens: 0,
completionTokens: 0,
lastFailedStep: null,
tracePath: `traces/run-${id}.jsonl`,
};
return taskStore.run(task, () => work(task));
}
export function estimateCost(model, usage, pricing) {
const rate = pricing.models[model];
if (!rate) throw new Error(`no pricing entry for ${model}`);
const cached = usage.prompt_tokens_details?.cached_tokens ?? 0;
const fresh = (usage.prompt_tokens ?? 0) - cached;
return (
(fresh / 1e6) * rate.inputPerMTok +
(cached / 1e6) * rate.cachedInputPerMTok +
((usage.completion_tokens ?? 0) / 1e6) * rate.outputPerMTok
);
}Wrapping the Model Call Without Leaking State
The wrapper opens a step, times it, reads usage, records it, and returns the parsed response unchanged. Nothing downstream needs to know tracing exists.
import { currentTask, estimateCost } from "./tracer.js";
export async function callModel({ messages, tools, signal, toolName, retryOf }) {
const task = currentTask();
const stepIndex = task.stepIndex++;
const started = performance.now();
const res = await fetch(`${process.env.LLM_BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.LLM_API_KEY}`,
},
body: JSON.stringify({ model: task.model, messages, tools, stream: false }),
signal,
});
if (!res.ok) throw new Error(`model call failed: ${res.status}`);
const data = await res.json();
const usage = data.usage ?? {};
const record = {
taskId: task.id,
stepIndex,
model: task.model,
promptTokens: usage.prompt_tokens ?? null,
completionTokens: usage.completion_tokens ?? null,
cachedTokens: usage.prompt_tokens_details?.cached_tokens ?? 0,
toolName: toolName ?? null,
latencyMs: Math.round(performance.now() - started),
retryOf: retryOf ?? task.lastFailedStep,
projectedCostUsd: estimateCost(task.model, usage, pricing),
};
task.promptTokens += record.promptTokens ?? 0;
task.completionTokens += record.completionTokens ?? 0;
await appendFile(task.tracePath, JSON.stringify(record) + "
");
return data;
}The Trace Record Shape
| Field | Why it exists |
|---|---|
taskId | Joins every step back to one user intent |
stepIndex | Orders the loop; shows where context growth lands |
model | Needed once you route steps to different models |
promptTokens | The resend cost, which is the dominant term |
completionTokens | Output billed per step, including throwaway reasoning |
cachedTokens | Separates discounted input from full-price input |
toolName | Attributes context growth to the tool that caused it |
latencyMs | Cheap to capture, useful for spotting retry storms |
retryOf | Turns a flat token count into a diagnosis |
projectedCostUsd | Converts the step to money at trace time |
retryOf and toolName are what make this a diagnostic. Without them you know a task was expensive; with them you know the fetch tool added 3.4k prompt tokens at step 2 and every step after paid for it. JSONL is the format because appends from concurrent tasks stay line-atomic, and the file replays into jq, DuckDB, or anything else without a parser.
Hard Budget Limits Instead of Post-Hoc Alerts
A warning that fires after the spend is a postmortem, not a control. I have watched an alerting threshold fire at the end of a run that had already cost more than the previous week. Enforce limits inside the loop: maxSteps, maxTokens, and maxCostCents per task, checked before the money is spent, aborting through an AbortController, and failing closed with a typed error the caller can catch.
export class BudgetExceededError extends Error {
constructor(kind, actual, limit) {
super(`budget exceeded: ${kind} ${actual} > ${limit}`);
this.name = "BudgetExceededError";
this.kind = kind;
this.actual = actual;
this.limit = limit;
}
}
export function assertBudget(task, nextStepPromptTokens, controller) {
const spent = task.promptTokens + task.completionTokens;
const projected = spent + nextStepPromptTokens;
const costCents = tokensToCostCents(task.model, projected);
if (task.stepIndex + 1 > task.limits.maxSteps) {
controller.abort();
throw new BudgetExceededError("maxSteps", task.stepIndex + 1, task.limits.maxSteps);
}
if (projected > task.limits.maxTokens) {
controller.abort();
throw new BudgetExceededError("maxTokens", projected, task.limits.maxTokens);
}
if (costCents > task.limits.maxCostCents) {
controller.abort();
throw new BudgetExceededError("maxCostCents", costCents, task.limits.maxCostCents);
}
}Where to Put the Budget Check
Three points, each with a different accuracy profile:
- Before each model call. Estimate the next prompt as current context length plus a max-output assumption. On a 12k context with
max_tokens: 800, budget 12,800 tokens. Honest but approximate: you are estimating a string you have not serialized yet, and tokenizers are not linear in characters. - After usage returns. Reconcile the estimate against the actual
usageblock and correct the running total. Without this, three underestimates compound into an overspend. - At the tool boundary. A single tool result can blow the context budget on its own, so check the size of the serialized tool output before it goes back into the message array.
Instrumenting Tools and Retries, Where the Cost Actually Hides
Tool calls themselves are cheap; their output is billed on every subsequent step. So log tool result size in tokens and truncate or summarize large payloads before re-injection. A 40k-token HTML dump at step 2 costs 40k on steps 3 through 9 as well. That is how one careless fetch becomes a 300k-token task.
Retries deserve explicit counting and attribution to the task. A retry loop is the most common route from a normal task to a runaway one, because each failed attempt resends the same context and stacks the failure on top of it. I record retries as their own trace records with retryOf set, so the report can answer "how many tokens did schema failures cost this week."
A Pricing Table That Stays Honest
Keep per-model input, cached-input, and output rates in a versioned config file, not as constants in the agent. Rates change, caching discounts change, and a stale table silently corrupts every cost number you have ever recorded.
{
"version": "2026-09-01",
"note": "placeholder rates for the example; replace with your provider's published numbers",
"models": {
"example-model-a": {
"inputPerMTok": 0,
"cachedInputPerMTok": 0,
"outputPerMTok": 0
}
}
}
The values above are zeroed placeholders by design. Fill them from your provider's pricing page before you trust a single cost figure. This table is where the trace becomes money, and the report's shift from per-token to per-task pricing is exactly why the unit of account should live in your config, not in your vendor's dashboard: when pricing models change, your definition of a task and your trace shape do not.
What the Traces Showed
I generated a fixture trace to show the report shape, because the columns are the point and exact values depend on your model and harness. The report runs against the JSONL the tracer already wrote:
node cost-report.js traces/run-9f3c*.jsonl
task 9f3c… model=example-model-a steps=7 prompt=18420 completion=1730
step 0 prompt=3120 completion=210 tool=- 12ms
step 1 prompt=3510 completion=180 tool=search 310ms
step 2 prompt=6930 completion=240 tool=fetch_doc 890ms
step 3 prompt=7010 completion=190 tool=- 640ms
step 4 prompt=4640 completion=260 tool=- 590ms retryOf=3
step 5 prompt=8120 completion=310 tool=lookup 740ms
step 6 prompt=9180 completion=340 tool=- 810ms
tokens-per-task 20150
dominant input step step 2 (+3420 prompt tokens from fetch_doc)
retry pair steps 3-4 (~4k prompt tokens duplicated)
cost-per-successful-task $0.0142 (fixture pricing)
The tool step at index 2 dominated input tokens, and it kept dominating because context is resent. The retry pair at steps 3 and 4 duplicated roughly 4k prompt tokens for one corrected output. Neither fact shows up in a monthly total.
Environment for the shape above: Node 22.x, a non-streaming OpenAI-compatible endpoint, and a hand-rolled loop harness with a seven-step cap. The trace format is reproducible; the numbers are fixture values.
Cutting Tokens-per-Task Without Cutting Quality
Ranked, with a clear priority order:
- Prune and summarize tool output before re-injection. Biggest win, least risk. Drop HTML tags, cap JSON depth, summarize long documents once.
- Cap loop iterations. A hard
maxStepsis a spend ceiling and a quality forcing function. - Route cheap classification steps to a smaller model. Real savings, but only after step 1.
- Cache stable prompt prefixes. Useful, provider-dependent, and fragile if your prefix drifts.
- Tighten structured-output schemas. Cuts parse-and-retry cycles, which are pure waste.
My position: routing and caching are optimization; pruning is hygiene. If you are doing 3 and 4 while shipping raw tool output back into context, you are optimizing the wrong term.
What This Measurement Misses
- Streaming responses often report usage only in a final chunk, so a stream cancelled mid-flight may produce no trace record at all. Untested in my harness; likely provider-specific.
- Reasoning tokens are billed but not exposed in every provider's response. If your provider hides them,
completionTokensundercounts and your cost estimate is optimistic. - Cached-token discounts vary by provider and change how the same trace prices out.
- Local tokenizer counts disagree with the billed count on many models. I treat the
usageblock as the source of truth and local counts as estimates only.
None of these are solved here. They are known gaps in the instrument.
Where I Land on Per-Token Versus Per-Task Pricing
The pricing model shift in the report only matters to builders who can already compute their own cost per task. If you cannot attribute spend to a task, a per-task price is just a different number you cannot verify. Instrumentation and a hard spend ceiling belong in the agent runtime from day one, not bolted on after the first surprise invoice. A dashboard tells you the bill. A tracer plus a budget check tells you the fix.
Further Reading
- Agentic AI Can Raise Token Use per Task Up to 100 Times (Business Wire, 2026-09-25) — press release carrying the Futurum Research finding, via Google News aggregation.
- Node.js
AsyncLocalStoragedocumentation — official API reference for the task context used here. - Node.js
async_hooksdocumentation — the underlying hook module. - OpenAI Chat Completions object reference — documents the
usagefields (prompt_tokens,completion_tokens,prompt_tokens_details.cached_tokens) the tracer reads. - AbortController on MDN — the cancellation primitive used in the budget check.


