Skip to content

fix(native-inference): reserve one token for uncapped AOSP generations - #35820

Merged
lalalune merged 1 commit into
elizaOS:developfrom
greatcodeeer:fix/aosp-uncapped-output-reserve
Oct 12, 2026
Merged

lalalune merged 1 commit into
elizaOS:developfrom
greatcodeeer:fix/aosp-uncapped-output-reserve

Conversation

@greatcodeeer

Copy link
Copy Markdown
Contributor

Relates to

No issue. No open or closed issue or PR covers this defect. Searched: createAospPreparedTextRequestGuard, resolveAospCompletionBudget, createPreparedModelRequestGuard, AOSP MODEL_INPUT_OVER_BUDGET, aosp outputReserveTokens, aosp maxTokens budget, dispatchThresholdTokens, aosp context budget. Related closed work: #30136 and #29839 (the resolveAospCompletionBudget contract), #32843 (mtcow, uncapped bionic chat turns).

Change

On AOSP (ELIZA_LOCAL_LLAMA=1), the fused loader in plugins/plugin-native-inference/src/aosp-local-inference-bootstrap.ts is the only local text backend. When a caller gives no maxTokens, resolveAospCompletionBudget sets the decode limit to the rest of the context (contextSize - promptTokens). This is its documented contract ({ contextSize: 4096, promptTokenCount: 1000 } -> 3096).

createAospPreparedTextRequestGuard then gave that same number to the core prepared-request guard as outputReserveTokens. The guard threshold is contextWindowTokens - outputReserveTokens, so the threshold became the prompt token count itself. The guard rejects inputTokens >= threshold, so every request without maxTokens failed with MODEL_INPUT_OVER_BUDGET before the stream opened, whatever the prompt size.

Callers that send no maxTokens include the REPLY action rewrite (plugins/plugin-assistant/src/features/basic-capabilities/actions/reply.ts, TEXT_LARGE) and grounded action replies (plugins/plugin-assistant/src/actions/grounded-action-reply.ts, TEXT_SMALL). The core dispatcher adds maxTokens only when MAX_TOKENS settings exist.

Fused loader generate() (probe below), 1000-token prompt, contextSize 4096 develop this branch
No maxTokens MODEL_INPUT_OVER_BUDGET (reserve 3096, threshold 1000), stream not opened Stream opens, decode limit 3096
maxTokens: 256 Stream opens Stream opens (unchanged)

Now the guard reserves the caller's explicit cap when there is one. Without a cap, it reserves one token, the room to begin decoding. The native decode limit (config.maxTokens) does not change.

Why this implementation

  • The iOS native path (plugins/plugin-native-inference/src/ios/native-generation.ts) already uses this rule: outputReserveTokens: args.requestedMaxTokens ?? 1, with the comment "With no explicit output request, reserve only room to begin decoding." This change uses the same rule for AOSP.
  • The core guard is not changed. Its strict threshold is correct for the other providers, which pass a real output reservation.
  • The fix adds one optional field (requestedMaxTokens) to the internal guard arguments, because the guard cannot know from config.maxTokens alone whether the caller asked for a cap. generate() passes args.maxTokens into it. 8 lines change in the source file.
  • The tests extend the existing __tests__/aosp-local-inference-bootstrap.test.ts suite. One case is the uncapped request (it fails on develop). One case keeps the explicit-cap reservation and rejection.

Testing

How to test

Use Bun 1.4.2 (the repository pin).

cd plugins/plugin-native-inference
bun test --conditions eliza-source __tests__/aosp-local-inference-bootstrap.test.ts

Expected: 41 pass. On develop 78d8235 with the same test file: 40 pass, 1 fail (admits an uncapped request that may decode into the rest of the context, MODEL_INPUT_OVER_BUDGET).

  • Targets develop and has no conflicts with the latest origin/develop. (The branch is one commit on develop 78d8235.)
  • Ran bun install and bun run verify after syncing; record blockers below.
  • Updated relevant documentation. (No documentation describes the AOSP admission reserve.)
cd plugins/plugin-native-inference
bun test --conditions eliza-source __tests__/aosp-local-inference-bootstrap.test.ts   # develop 78d823507: 40 pass, 1 fail; this branch: 41 pass
bun test --conditions eliza-source __tests__                                         # 65 pass, 0 fail (6 files)
tsc -p <probe tsconfig: the test file + aosp-local-inference-bootstrap.ts>            # only the existing TS2367 at test line 266 (also on develop); an injected error in the new test block is reported
biome check <both changed files>                                                      # clean

Evidence Gate

Evidence must match the reviewed commit. packages/scripts/pr-evidence.ts rows
sets the marker from the live PR head; rerun after each push.

Keep every row. Attach artifacts inline or write N/A - <reason> where
inapplicable. UI changes require desktop/mobile before-and-after screenshots,
an uploaded MP4 explainer and walkthrough, video proof of the changed flow,
logs, and OCR review. Screenshots do not replace video. Use JPG images where possible.
Keep generated artifacts out of source control.

N/A - no UI change; the rejected request before the fix is recorded under Evidence Details.

N/A - no UI change; the admitted request after the fix is recorded under Evidence Details.

N/A - no UI change; only the AOSP native loader admission changes.

N/A - no AOSP device was available; the loader probe output and its limits are under Evidence Details.

N/A - no frontend code changes; admission runs in the agent process on the device.

N/A - no prompt, action or model output changes; the fix only admits requests that the guard rejected before any model call.

N/A - no stored data, files or schedules change; the loader output is shown in the backend-logs row.

N/A - no rendered visual surface changes in this native loader fix.

Evidence Details

Red: develop 78d8235 with the new tests
bun test v1.4.2 (744846f84)
__tests__/aosp-local-inference-bootstrap.test.ts:
ElizaError: Complete provider-prepared model request exceeds its context budget
      at createPreparedModelRequestGuard (packages/core/src/runtime/prepared-model-request.ts:322:3)
      at <anonymous> (plugins/plugin-native-inference/__tests__/aosp-local-inference-bootstrap.test.ts:468:19)
(fail) createAospPreparedTextRequestGuard > admits an uncapped request that may decode into the rest of the context [3.31ms]
 40 pass
 1 fail
Ran 41 tests across 1 file. [304.00ms]
Green: 6d25725
bun test --conditions eliza-source __tests__/aosp-local-inference-bootstrap.test.ts
 41 pass
 0 fail
 88 expect() calls
Ran 41 tests across 1 file. [302.00ms]
Loader probe: develop 78d8235 and 6d25725
Real tryBuildAospFusedTextLoader() -> loadModel({contextSize: 4096}) -> generate(), fake bun:ffi tokenizer (1000 prompt tokens) and fake stream
develop 78d82350 (before): maxTokens=omitted -> THROW MODEL_INPUT_OVER_BUDGET {"inputTokens":1000,"outputReserveTokens":3096,"dispatchThresholdTokens":1000} streamOpened=0
develop 78d82350 (before): maxTokens=256     -> OK streamOpened=true config.maxTokens=256
fix 6d257258 (after):      maxTokens=omitted -> OK streamOpened=true config.maxTokens=3096
fix 6d257258 (after):      maxTokens=256     -> OK streamOpened=true config.maxTokens=256

The loader probe was a temporary test (not committed). It drove the real tryBuildAospFusedTextLoader() loader, loadModel and generate with a fake bun:ffi module (the tokenizer returns 1000 tokens) and a fake streamGenerate that records the stream config. No device was used, so it is not a device log.

Prior work: the iOS native path (ios/native-generation.ts) already reserves requestedMaxTokens ?? 1. #30238 (lalalune) restored the resolveAospCompletionBudget contract that #30136 and #29839 described.

Known gaps / failures

  • Not run on an AOSP device. The probe uses the real loader code with a fake native library.
  • bun run verify (the full monorepo gate) was not run locally. The suites above and CI on the PR head cover the change.
  • The guard rejects inputTokens >= threshold, while resolveAospCompletionBudget accepts an explicit maxTokens equal to the remaining context. A request with prompt + maxTokens == contextSize exactly is still rejected. This PR does not change that boundary.

Deployment

None.

🤖 Generated with Claude Code

AI provider/model: anthropic / claude-opus-5-5
Client / agent tooling: claude-code
Contribution skill revision: N/A - ordinary GitHub contribution without optional run evidence
Attribution status: self-reported
— [contributor]

The fused AOSP loader resolves an absent maxTokens to the whole remaining
context (contextSize - promptTokens) and passed that decode ceiling to the
prepared-request guard as its output reservation. The guard's threshold
then equals the prompt token count, so every uncapped TEXT_SMALL /
TEXT_LARGE call (REPLY rewrite, grounded action reply) failed with
MODEL_INPUT_OVER_BUDGET before the stream opened.

Reserve the caller's explicit cap when there is one, and otherwise only
room to begin decoding, as the iOS native path already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mtcow mtcow left a comment

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.

Reviewed at head 6d25725

The fix addresses a real, unconditional-rejection bug. Before this change the guard reserved args.config.maxTokens (plugins/plugin-native-inference/src/aosp-local-inference-bootstrap.ts:2976), and for an uncapped request config.maxTokens is not a cap at all — it is resolveAospCompletionBudget(...) = contextSize - promptTokenCount (aosp-local-inference-bootstrap.ts:3421-3427, applied to config at :3428-3429). Feeding that into createPreparedModelRequestGuard makes dispatchThresholdTokens = contextWindowTokens - (contextWindowTokens - promptTokenCount) = promptTokenCount (packages/core/src/runtime/prepared-model-request.ts:289-292), and admission rejects on counted.count >= dispatchThresholdTokens (:312-315) — i.e. promptTokenCount >= promptTokenCount, always true. So every maxTokens-less TEXT_LARGE/TEXT_SMALL generation was rejected at guard construction, before any dispatch. Reserving 1 for the uncapped case is the correct shape and mirrors the iOS path.

Findings

  • plugins/plugin-native-inference/src/aosp-local-inference-bootstrap.ts:2976 — outputReserveTokens: args.requestedMaxTokens ?? 1. Traced against packages/core/src/runtime/prepared-model-request.ts:279-292: with an explicit cap the reserve equals the caller's cap, so dispatchThresholdTokens = contextSize - requestedMaxTokens; with no cap the threshold becomes contextSize - 1, which is exactly the widest input resolveAospCompletionBudget will ever hand this path (remainingTokens > 0, :560-569). The comment's distinction between "decode ceiling" and "reservation" is accurate.
  • plugins/plugin-native-inference/src/aosp-local-inference-bootstrap.ts:3449 — threads the raw caller value args.maxTokens, not the resolved config.maxTokens. This is the load-bearing half of the fix: reserving the derived ceiling was the bug, reserving the caller's explicit cap is the intent. It also keeps projectRequest's embedded config (:2983) consistent, since resolveAospCompletionBudget returns requestedMaxTokens verbatim when it fits (:586). Semantics match plugins/plugin-native-inference/src/ios/native-generation.ts:42-44 (args.requestedMaxTokens ?? 1).
  • plugins/plugin-native-inference/__tests__/aosp-local-inference-bootstrap.test.ts:48-92 — the two cases are discriminating. Case 1 (:63-70) asserts outputReserveTokens: 1 / dispatchThresholdTokens: 4095 / attempts === 1; on base code the guard would throw MODEL_INPUT_OVER_BUDGET at construction (threshold 1000), so assertBeforeAttempt() could never run. Case 2 (:73-92) pins the capped path (outputReserveTokens: 256, threshold 3840) and asserts the over-budget throw at promptTokenCount: 3900. Neither test is a tautology.

Residual notes (pre-existing in kind; not introduced here, no block)

  • Off-by-one between the two budget authorities: the guard rejects on counted.count >= threshold (prepared-model-request.ts:314), so with reserve 1 only promptTokenCount <= contextWindowTokens - 2 is admitted, while resolveAospCompletionBudget admits remainingTokens == 1 (aosp-local-inference-bootstrap.ts:560-569). Likewise a capped request whose requestedMaxTokens === remainingTokens is admitted by resolve (only > throws, :577) but then rejected by the guard because threshold collapses to promptTokenCount. The guard is consistently one token stricter than "room to begin decoding" implies; worth a follow-up assertion so the two seams can't drift.
  • aosp-local-inference-bootstrap.ts:3425 uses contextSize ?? 32768 while the guard's contextWindowTokens at :3445-3446 uses contextSize ?? readPositiveIntEnv("ELIZA_LLAMA_N_CTX", 4096). When active.contextSize is unset these two fallbacks disagree, so the resolved maxTokens is budgeted against a 32k window while the guard admits against 4k. Not part of this diff, but it is the same "two authorities, two windows" smell.

Live execution blocked: no test runner in the scratch checkout — node_modules, but no node_modules/.bin (no vitest), and npx vitest aborts with EOVERRIDE from the @playwright/test override conflict; a monorepo-wide bun install is out of this run's budget. The findings above are a static trace over the actual head sources (prepared-model-request.ts, aosp-local-inference-bootstrap.ts, ios/native-generation.ts) with exact line references; the arithmetic is deterministic and the new test cases were checked against base behaviour by hand.

Verdict: accept — the root cause (uncapped generations rejected at the guard because the derived decode ceiling was reserved as if it were a cap) is real, the one-token reservation plus threading args.maxTokens is the minimal correct fix, and the new tests are discriminating; the only gaps are pre-existing off-by-one/fallback mismatches that belong in a follow-up rather than this PR.

Compute receipt: 0 project-attributed tokens (unavailable; device-signed, locally reported)
AI provider/model: deepseek / deepseek-v4-flash
Client / agent tooling: review-lane-free
Contribution skill revision: SlopDotCash/slopdotcash@fa576cd:skills/contribute-to-eliza
Attribution status: self-reported
— [review]

@MLuber-bot MLuber-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Independent review — reserve one token for uncapped AOSP generation

Read the diff and aosp-local-inference-bootstrap.ts at head 6d257258.

  • In the fused loader's generate, resolveAospCompletionBudget returns contextSize - promptTokenCount when the caller gives no maxTokens. That value became config.maxTokens, and createAospPreparedTextRequestGuard used it as outputReserveTokens. So for an uncapped request the guard reserved the whole remaining context as output, and the prompt plus that reserve landed exactly at the context size, or over after the guard's dispatch margin. An uncapped call (the PR names REPLY and grounded replies) could therefore be rejected as "exceeds its context budget" even though the prompt fits.
  • The fix threads the caller's cap through as requestedMaxTokens and reserves requestedMaxTokens ?? 1. An explicit cap still reserves its tokens, and uncapped requests reserve just enough to begin decoding. resolveAospCompletionBudget still throws MODEL_PROMPT_TOO_LARGE when no room is left, so a prompt that truly fills the context is still refused.
  • The decode ceiling is unchanged (config.maxTokens is still the remaining context), and the MODEL_OUTPUT_INCOMPLETE check on result.accepted >= maxTokens still catches output that runs to the boundary. So this does not let uncapped output silently truncate.
  • The new tests cover both branches: uncapped admitted at 1000/4096 with reserve 1, and a capped 256 request rejected at a 3900-token prompt.

Non-blocking: the guard's contextWindowTokens falls back to ELIZA_LLAMA_N_CTX (4096) while the budget falls back to 32768 when active.contextSize is unset. That mismatch was there before, but it only matters if contextSize is missing. I did not run the tests.
AI provider/model: anthropic / claude-sonnet-5-5
Client / agent tooling: claude-code
Contribution skill revision: N/A - ordinary GitHub contribution without optional run evidence
Attribution status: self-reported
— [contributor]

@lalalune

Copy link
Copy Markdown
Member

The AOSP bootstrap suite passed (41 tests), including uncapped generation context accounting.

Reviewed for useful behavior and regressions. Combined integration at 7ac11e601a9a79e9010d919e5f5cbaf65a05ea36 passed bun run verify (309 tasks plus package-consumer and repository audits), using Bun 1.4.2 and Node 24.15.0. Current-head hosted checks are green. No live-provider or device acceptance is claimed.

@lalalune
lalalune merged commit ba5af37 into elizaOS:develop Oct 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants