Measuring Tokens-per-Task in JavaScript Agents with Per-Step Cost Tracing and Budget Limits

Measuring Tokens-per-Task in JavaScript Agents with Per-Step Cost Tracing and Budget Limits

pr0h0•
javascriptai-agentstoken-economicsobservabilitycost-optimization
AI Usage (94%)

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.

tracer.js
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.

call-model.js
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

FieldWhy it exists
taskIdJoins every step back to one user intent
stepIndexOrders the loop; shows where context growth lands
modelNeeded once you route steps to different models
promptTokensThe resend cost, which is the dominant term
completionTokensOutput billed per step, including throwaway reasoning
cachedTokensSeparates discounted input from full-price input
toolNameAttributes context growth to the tool that caused it
latencyMsCheap to capture, useful for spotting retry storms
retryOfTurns a flat token count into a diagnosis
projectedCostUsdConverts 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.

budget.js
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:

  1. 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.
  2. After usage returns. Reconcile the estimate against the actual usage block and correct the running total. Without this, three underestimates compound into an overspend.
  3. 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:

  1. Prune and summarize tool output before re-injection. Biggest win, least risk. Drop HTML tags, cap JSON depth, summarize long documents once.
  2. Cap loop iterations. A hard maxSteps is a spend ceiling and a quality forcing function.
  3. Route cheap classification steps to a smaller model. Real savings, but only after step 1.
  4. Cache stable prompt prefixes. Useful, provider-dependent, and fragile if your prefix drifts.
  5. 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, completionTokens undercounts 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 usage block 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

Share this post

More posts

Comments