Skip to content

feat(context): per-step budget guard compacts the AI-SDK tool loop before overflow - #1221

Merged
murdore merged 1 commit into
releasefrom
feat/ai-sdk-step-budget-guard
Jul 21, 2026
Merged

murdore merged 1 commit into
releasefrom
feat/ai-sdk-step-budget-guard

Conversation

@pdogra1299

@pdogra1299 pdogra1299 commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Pre-call budgeting (checkContextBudget + compaction in neurolink.ts) runs ONCE before dispatch and only sees the input/session conversation. The AI-SDK agent loop (GenerationHandler.callGenerateText, up to DEFAULT_MAX_STEPS=200 steps) then appends assistant turns and tool results on every step — growth the pre-call pipeline never sees. Long agentic runs overflow the model's real window mid-loop: juspay/yama#61 CI accumulated ~246K input tokens against a 262K window (and double NeuroLink's own 128K litellm default) before the backend 400'd with ContextWindowExceededError.

The googleVertex native loops already guard this (createContextGuard, checked every iteration, DEFAULT_CONTEXT_GUARD_RATIO 0.85) — but as stop-only. The AI-SDK path, anthropic/bedrock native loops, and the chat-completions stream loop have nothing.

Fix

context/stepBudgetGuard.ts + wiring in callGenerateText via experimental_prepareStep (whose result may replace the step's messages — the sanctioned AI-SDK hook). Every step:

  1. Estimate the projected request (step messages + hoisted system prompt + tool definitions) against getAvailableInputTokens() × DEFAULT_CONTEXT_GUARD_RATIO — same ratio as the Vertex guard.
  2. Over threshold → deterministically reclaim budget and continue (an upgrade over Vertex's stop-only): Stage 1 truncates OLD tool outputs to head/tail previews (generateToolOutputPreview); Stage 2 drops the oldest complete tool exchanges (assistant tool-call + its tool-result messages as a unit, preserving call/result pairing) and inserts one elision note.
  3. Never touches: the first user (task) message, the protected recent tail, or anything when under threshold (zero overhead no-op).

Operates on ModelMessage[] natively (no lossy ChatMessage round-trip), makes no LLM calls, and composes with a caller-supplied prepareStep (caller result wins; the guard only contributes messages).

Tests

test/stepBudgetGuard.test.ts (7 tests, wired into test:unit): estimation scaling, overhead accounting, no-op under threshold, stage-1 truncation with protected tail, stage-2 pair-integral drops with elision note, fixed-overhead trigger, protected-tail immunity. test:anthropic-tools-policy and test:mcp:limits pass unchanged.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a per-step context budget guard for AI tool-driven message loops, including deterministic token estimation and safe message compaction.
    • Generation now automatically applies the guard to prevent context overflow while preserving the most recent conversation tail.
  • Bug Fixes
    • Helps prevent long-running tool workflows from exceeding model context limits by reducing step history before sending.
  • Tests
    • Added a dedicated Vitest suite covering token estimation, tool-output truncation, history preservation, and overhead/budget decisions.
  • Chores
    • Expanded unit test scripts to include the new guard test suite.

@vercel

vercel Bot commented Jul 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
neurolink Ready Ready Preview, Comment Jul 21, 2026 6:39am

@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a per-step context budget guard that estimates token usage, truncates older tool outputs, removes older tool exchanges when needed, and integrates compaction into GenerationHandler. New Vitest coverage and unit-test wiring validate the behavior.

Changes

Step budget guard

Layer / File(s) Summary
Budget configuration and token estimation
src/lib/types/context.ts, src/lib/context/stepBudgetGuard.ts
Adds StepBudgetGuardConfig and token estimators for step messages plus system and tool overhead.
Deterministic message compaction
src/lib/context/stepBudgetGuard.ts, test/stepBudgetGuard.test.ts
Truncates older tool results, removes complete older tool exchanges when required, preserves the protected tail, and tests threshold and elision behavior.
Generation loop integration and test wiring
src/lib/core/modules/GenerationHandler.ts, package.json
Runs caller preparation first, applies the guard to outgoing messages, and adds the guard suite to unit tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GenerationHandler
  participant prepareStep
  participant StepBudgetGuard
  participant Model
  GenerationHandler->>prepareStep: prepare step with original options
  prepareStep-->>GenerationHandler: return optional messages
  GenerationHandler->>StepBudgetGuard: estimate and compact outgoing messages
  StepBudgetGuard-->>GenerationHandler: return guarded messages or undefined
  GenerationHandler->>Model: generate text with final messages
Loading

Possibly related PRs

  • juspay/neurolink#1134: Both changes modify GenerationHandler.callGenerateText message handling and system prompt extraction.
  • juspay/neurolink#1136: Both changes implement or consume in-loop context-guard behavior and shared context-budget constants.

Suggested labels: released

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR does not implement issue #39's logging consolidation or debug-flag work; it adds an unrelated context-budget guard instead. Either retarget the linked issue or update the PR to include the logging consolidation, debug control, and logger migration requirements.
Out of Scope Changes check ⚠️ Warning All code changes are unrelated to the linked logging objectives and introduce a separate context-budget guard feature. Remove the context-budget guard changes or align the PR with the linked logging scope before merging.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the new per-step context budget guard and matches the main change.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ai-sdk-step-budget-guard

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/lib/context/stepBudgetGuard.ts

Parsing error: Unable to parse the specified 'tsconfig' file. Ensure it's correct and has valid syntax.

error TS5012: Cannot read file '/.svelte-kit/tsconfig.json': ENOENT: no such file or directory, open '/.svelte-kit/tsconfig.json'.

src/lib/core/modules/GenerationHandler.ts

Parsing error: Unable to parse the specified 'tsconfig' file. Ensure it's correct and has valid syntax.

error TS5012: Cannot read file '/.svelte-kit/tsconfig.json': ENOENT: no such file or directory, open '/.svelte-kit/tsconfig.json'.

src/lib/types/context.ts

Parsing error: Unable to parse the specified 'tsconfig' file. Ensure it's correct and has valid syntax.

error TS5012: Cannot read file '/.svelte-kit/tsconfig.json': ENOENT: no such file or directory, open '/.svelte-kit/tsconfig.json'.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

✅ Single Commit Policy - COMPLIANT

Status: Policy requirements met • 1 commit • Valid format • Ready for merge

📊 View validation details

📝 Commit Details

  • Hash: b638eb3dcd65cbc77b600a30b0586fc50d410e97
  • Message: feat(context): per-step budget guard compacts the AI-SDK tool loop before overflow
  • Author: Parth Dogra

✅ Validation Results

  • Single commit requirement met
  • No merge commits in branch
  • Semantic commit message format verified
  • Ready for squash merge to release branch

🤖 Automated validation by NeuroLink Single Commit Enforcement

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@Tara-ag Tara-ag 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.

Review Summary

Files reviewed: 5 changed files (package.json, src/lib/context/stepBudgetGuard.ts, src/lib/core/modules/GenerationHandler.ts, src/lib/types/context.ts, test/stepBudgetGuard.test.ts)

New issues raised: 7

  • 🔒 CRITICAL: 0
  • ⚠️ MAJOR: 2 (both on GenerationHandler.ts:220; related but distinct)
  • 💡 MINOR: 3
  • 💬 SUGGESTION: 2

Decision: REQUEST_CHANGES

The implementation is solid conceptually and the tests cover the core compaction logic well, but the experimental_prepareStep wrapper in GenerationHandler.ts changes the public prepareStep contract in a backward-incompatible way:

  1. Backward-compatibility break (blocking): The wrapper now passes guard-compacted messages into the caller’s prepareStep callback. Callers that inspect step messages (even if untyped) will see different inputs than before. Per CLAUDE.md Critical Rule 5, the public SDK API must not break existing callers without a documented migration path. The fix is straightforward: run the caller’s prepareStep on the original stepOptions.messages, then merge the guard’s compaction afterward.

  2. Silent override risk: The merge { ...(callerResult ?? {}), messages: compacted } discards any messages the caller returns without warning. Combined with the first issue, this makes the interaction between caller-supplied and guard-supplied message mutation unclear.

Other non-blocking notes:

  • Stage-2 token estimation is O(n²); can be made linear with a running total.
  • The elision note can be appended after the protected tail when all droppable exchanges are removed.
  • The new Vitest test file conflicts with CONTRIBUTING.md’s stated convention that *.test.ts files are not picked up; consider adding a continuous-test-suite-step-budget-guard.ts orchestrator as the canonical test.

Please address the prepareStep wrapping order so callers continue to receive the original step messages, and re-request review.

Comment thread src/lib/context/stepBudgetGuard.ts
Comment thread src/lib/context/stepBudgetGuard.ts
Comment thread src/lib/core/modules/GenerationHandler.ts
Comment thread src/lib/context/stepBudgetGuard.ts
Comment thread src/lib/core/modules/GenerationHandler.ts
Comment thread src/lib/core/modules/GenerationHandler.ts
Comment thread test/stepBudgetGuard.test.ts
Comment thread src/lib/types/context.ts
@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
test/stepBudgetGuard.test.ts (1)

219-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assertions never execute — test is vacuous.

With 4 messages and PROTECTED_TAIL_MESSAGES = 4, tailStart is 0 in both compaction stages, so nothing is ever eligible for truncation/dropping and guardStepMessages always returns undefined (the stage1.truncated===0 && droppedExchanges===0 short-circuit). The if (compacted) {...} block therefore never runs, and this test always passes regardless of whether the underlying protected-tail logic is correct.

✅ Proposed fix: assert the deterministic no-op outcome directly
     const compacted = guard(messages);
-    // The last exchange is within the 4-message protected tail — the guard
-    // must not mutate it, so either nothing changes or nothing structural does.
-    if (compacted) {
-      expect(compacted.length).toBe(messages.length);
-      const lastTool = compacted[compacted.length - 1] as {
-        content: Array<{ output: { value: string } }>;
-      };
-      expect(lastTool.content[0].output.value.length).toBe(50_000);
-    }
+    // The whole exchange sits inside the 4-message protected tail — the
+    // guard has nothing it is allowed to touch, so it must no-op.
+    expect(compacted).toBeUndefined();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/stepBudgetGuard.test.ts` around lines 219 - 243, Update the test case
around the guard invocation to assert the deterministic no-op result directly:
verify that guard(messages) returns undefined, and remove the unreachable if
(compacted) block and its nested structural/content assertions. Keep the
existing four-message protected-tail setup unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/context/stepBudgetGuard.ts`:
- Around line 139-165: Update the tool-result handling in the message content
mapping to serialize the actual output value using the appropriate variant-aware
serialization, rather than stringifying the structured result wrapper. Ensure
the resulting payload text is passed to generateToolOutputPreview while
preserving the existing size checks, truncation counters, and replacement
structure.

In `@src/lib/core/modules/GenerationHandler.ts`:
- Around line 194-213: Update the step budget guard setup around
createStepBudgetGuard so fixedOverheadTokens is recalculated from the current
toolsWithCache on every experimental_prepareStep invocation. Ensure newly
hydrated search_tools are included before enforcing the per-step budget, while
preserving the existing guard behavior and prepareStep message precedence.

---

Nitpick comments:
In `@test/stepBudgetGuard.test.ts`:
- Around line 219-243: Update the test case around the guard invocation to
assert the deterministic no-op result directly: verify that guard(messages)
returns undefined, and remove the unreachable if (compacted) block and its
nested structural/content assertions. Keep the existing four-message
protected-tail setup unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: da25a213-15b5-4ba6-937a-e3c6ab31d506

📥 Commits

Reviewing files that changed from the base of the PR and between 7b718cd and 5b8fd7d.

📒 Files selected for processing (5)
  • package.json
  • src/lib/context/stepBudgetGuard.ts
  • src/lib/core/modules/GenerationHandler.ts
  • src/lib/types/context.ts
  • test/stepBudgetGuard.test.ts

Comment thread src/lib/context/stepBudgetGuard.ts
Comment thread src/lib/core/modules/GenerationHandler.ts
@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/stepBudgetGuard.test.ts (1)

287-296: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the guard explicitly bails out for protected tails.

The if (compacted) block correctly catches unwanted mutations, but it misses the opportunity to assert the guard's actual bailout contract. When all exchanges fall within the protected tail and no budget can be safely reclaimed, the guard is designed to return undefined.

Replacing the conditional block with a strict toBeUndefined() assertion makes the test simpler and enforces the expected return type directly.

🛠️ Proposed fix for the assertion
     const compacted = guard(messages);
-    // The last exchange is within the 4-message protected tail — the guard
-    // must not mutate it, so either nothing changes or nothing structural does.
-    if (compacted) {
-      expect(compacted.length).toBe(messages.length);
-      const lastTool = compacted[compacted.length - 1] as {
-        content: Array<{ output: { value: string } }>;
-      };
-      expect(lastTool.content[0].output.value.length).toBe(50_000);
-    }
+    // The last exchange is within the protected tail. The guard must not mutate
+    // it, and should return `undefined` to signal that no changes were made.
+    expect(compacted).toBeUndefined();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/stepBudgetGuard.test.ts` around lines 287 - 296, Update the
protected-tail test around guard(messages) to assert directly that the result is
undefined when all exchanges are protected and no budget can be reclaimed.
Remove the conditional structural/content assertions, preserving the existing
setup and message expectations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/stepBudgetGuard.test.ts`:
- Around line 152-170: Update the pairing check in the result validation loop to
remove each toolCallId from the tracking set when its matching tool-result is
encountered, while still asserting the result has a preceding call. After
processing all messages, assert that the set is empty so retained tool-calls
cannot remain orphaned.

---

Nitpick comments:
In `@test/stepBudgetGuard.test.ts`:
- Around line 287-296: Update the protected-tail test around guard(messages) to
assert directly that the result is undefined when all exchanges are protected
and no budget can be reclaimed. Remove the conditional structural/content
assertions, preserving the existing setup and message expectations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 93fcfeba-5fb4-4146-83c5-02ba58caaf4e

📥 Commits

Reviewing files that changed from the base of the PR and between 5b8fd7d and b638eb3.

📒 Files selected for processing (5)
  • package.json
  • src/lib/context/stepBudgetGuard.ts
  • src/lib/core/modules/GenerationHandler.ts
  • src/lib/types/context.ts
  • test/stepBudgetGuard.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/lib/types/context.ts
  • package.json
  • src/lib/context/stepBudgetGuard.ts
  • src/lib/core/modules/GenerationHandler.ts

Comment on lines +152 to +170
// Pairing integrity: every remaining tool message's toolCallId has a
// preceding assistant tool-call with the same id.
const callIds = new Set<string>();
for (const message of result) {
if (!Array.isArray(message.content)) {
continue;
}
for (const part of message.content as Array<{
type?: string;
toolCallId?: string;
}>) {
if (part.type === "tool-call" && part.toolCallId) {
callIds.add(part.toolCallId);
}
if (part.type === "tool-result" && part.toolCallId) {
expect(callIds.has(part.toolCallId)).toBe(true);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Verify that no orphaned tool-calls remain.

The current pairing integrity check verifies that every retained tool-result has a preceding tool-call, but it doesn't verify the reverse: that every retained tool-call has a matching tool-result. If the guard dropped a tool-result but left its tool-call hanging, this test would falsely pass.

Remove matched IDs from the set and assert that the set is empty at the end to ensure complete bidirectional pairing.

🛠️ Proposed fix to ensure bidirectional pairing
         if (part.type === "tool-result" && part.toolCallId) {
           expect(callIds.has(part.toolCallId)).toBe(true);
+          callIds.delete(part.toolCallId);
         }
       }
     }
+
+    // Ensure no tool-calls were left without matching tool-results.
+    expect(callIds.size).toBe(0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Pairing integrity: every remaining tool message's toolCallId has a
// preceding assistant tool-call with the same id.
const callIds = new Set<string>();
for (const message of result) {
if (!Array.isArray(message.content)) {
continue;
}
for (const part of message.content as Array<{
type?: string;
toolCallId?: string;
}>) {
if (part.type === "tool-call" && part.toolCallId) {
callIds.add(part.toolCallId);
}
if (part.type === "tool-result" && part.toolCallId) {
expect(callIds.has(part.toolCallId)).toBe(true);
}
}
}
// Pairing integrity: every remaining tool message's toolCallId has a
// preceding assistant tool-call with the same id.
const callIds = new Set<string>();
for (const message of result) {
if (!Array.isArray(message.content)) {
continue;
}
for (const part of message.content as Array<{
type?: string;
toolCallId?: string;
}>) {
if (part.type === "tool-call" && part.toolCallId) {
callIds.add(part.toolCallId);
}
if (part.type === "tool-result" && part.toolCallId) {
expect(callIds.has(part.toolCallId)).toBe(true);
callIds.delete(part.toolCallId);
}
}
}
// Ensure no tool-calls were left without matching tool-results.
expect(callIds.size).toBe(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/stepBudgetGuard.test.ts` around lines 152 - 170, Update the pairing
check in the result validation loop to remove each toolCallId from the tracking
set when its matching tool-result is encountered, while still asserting the
result has a preceding call. After processing all messages, assert that the set
is empty so retained tool-calls cannot remain orphaned.

@murdore
murdore merged commit 0dc64de into release Jul 21, 2026
17 checks passed
@murdore
murdore deleted the feat/ai-sdk-step-budget-guard branch July 21, 2026 07:51
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 10.3.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

This branch was successfully deployed

1 active deployment
Preview — b638eb3d Deployed Jul 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants