Skip to content

--budget cuts on item boundaries and always emits one item, but docs describe it as a cap #60

Description

@TMYTiMidlY

Edited. The original version contained three errors: it said two budgets were "1600x apart" (they are 500x), it claimed "nothing on stderr indicates" the overflow when those particular repros do print a hidden-result note, and it stated the floor is "the size of the largest single item" when it is actually the first eligible item at the current offset. It also described the item unit as records, which is only true for JSON output. All corrected below, and a repro where stderr really is silent has been added.

The contract

  • README.md:23 — "--budget <tokens> caps output by estimated tokens"
  • src/commands/root.ts:63 — "--budget <t> cap output at ~t tokens; truncation is never silent"
  • src/agent-context.txt:49 — "Results cap at 50 (--limit n / --all / --budget <tokens>)"

These read as a ceiling. The implementation is a soft, item-boundary target that always makes progress.

1. An oversized item can exceed the budget with completely silent stderr

python3 - <<'PY'
rows = ''.join(f'<tr><td>item{i}</td><td>{i*7}</td></tr>' for i in range(1, 101))
t = f'<table><tr><th>name</th><th>price</th></tr>{rows}</table>'
open('two.html','w').write(f'<html><body>{t}{t}</body></html>')
PY

ax two.html table --table --offset 1 --budget 1 | wc -c
# 6660          (budget 1 => 4 chars)

ax two.html table --table --offset 1 --budget 1 2>&1 >/dev/null | cat -A
# (completely empty)

With --offset 1 nothing remains to be hidden after the emitted item, so no note is produced — and the emitted item is ~1665x the requested budget. Without the offset, ax does report 1 more result(s) hidden, but never that the item it did emit blew the budget.

2. What the code actually does

src/lib/emit.ts:36-45:

if (opts.budget && opts.budget > 0) {
  const maxChars = opts.budget * CHARS_PER_TOKEN
  let used = 0
  let i = 0
  for (; i < shown.length; i++) {
    used += sizeOf(shown[i]!)
    if (used > maxChars && i > 0) break   // <- i > 0
  }
  shown = shown.slice(0, i)
}

Two properties, neither documented:

  1. The cut lands on item boundaries; an item is never split.
  2. && i > 0 makes the first eligible item at the current offset unconditional, however large it is.

So --budget T means "emit whole items in order, stopping before the one that would push the total past T, but always emit at least one". The floor is the size of that first eligible item — not the largest item overall. A small first item followed by a huge one still emits only the small one:

# table 1 = 1 row, table 2 = 200 rows
ax sb.html table --table --budget 1              # 99 chars
ax sb.html table --table --budget 1 --offset 1   # 7155 chars

The item unit also varies:

output item
--md a line
--row / single-table --table, default TSV a line, including the header (root.ts:1066,1090emit.ts:109-110)
--row / --table with --json a record
selector matching multiple tables an entire table (root.ts:1063-1065)

That last row is where the floor becomes unbounded, and it is reachable with a selector an agent would plausibly write (table.wikitable matches 2 tables on https://en.wikipedia.org/wiki/List_of_countries_by_GDP_(nominal)).

Nothing in README.md, src/commands/root.ts, src/agent-context.txt or skills/ax/SKILL.md mentions item granularity or the always-emit-one rule — no matches for whole, entire, at least one, or boundary.

3. JSON budget accounting excludes indentation

emit.ts:126,135 size items with JSON.stringify(v).length + 4 (compact), while :127,136 emit pretty-printed JSON with 2-space indentation, so the budget is measured against something smaller than what is written. The TSV path (emit.ts:110, s.length + 1) does not have this discrepancy.

Expected

I assume whole-item cuts and first-item progress are intentional (an empty result for a small budget would be useless, and test/cli.test.ts:516-518 looks like it pins this). The asks are therefore:

  1. Document the semantics, e.g.:

    --budget truncates at item boundaries, never splits an item, and always emits at least one item — so output can exceed the budget. The item unit depends on output: lines for --md and TSV, records for --json, and an entire table when the selector matches more than one table.

  2. Announce it on stderr when the emitted item alone exceeds the budget. That path is currently silent, which is the one case that sits awkwardly next to "truncation is always announced on stderr, never silent".

  3. Either include indentation in the JSON sizing, or state that the budget is measured against compact JSON.

Environment: ax 0.1.23, Linux x86_64 (WSL2)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions