Skip to content

feat(experiment): cap per-turn output tokens and steer on truncation - #927

Draft
pmateusz wants to merge 2 commits into
masterfrom
experiment/max-output-tokens
Draft

feat(experiment): cap per-turn output tokens and steer on truncation#927
pmateusz wants to merge 2 commits into
masterfrom
experiment/max-output-tokens

Conversation

@pmateusz

@pmateusz pmateusz commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Linked issue

Closes #0

What does this PR do?

Cap per-turn output tokens and steer on truncation

Problem

TB-2.1 benchmark analysis found output tokens generated and then discarded because
the BackendConfig (timeoutSec: 660) severs any request exceeding 11 minutes regardless of activity.
The largest single discarded turn was 487k tokens. Nothing bounded this - the thinking dial is advisory,
and pi never sends an output limit on the openai-completions path.

Solution

A before_provider_request extension that:

  1. Caps max_tokens fitted to remaining context window (vLLM 400s if prompt + max_tokens > context_window,
    so a flat cap would introduce a hard 400 that doesn't exist today)
  2. Detects truncation (finish_reason: "length") and sends a steering message that triggers a new turn,
    instead of the agent silently treating the truncated response as a finished answer
  3. Limits consecutive truncations to prevent infinite looping if the model never adapts

Registered on subagent sessions as well — they do the long exploratory work most likely to run away.

Configuration

  • Default: 32,000 tokens
  • Override: KIMCHI_MAX_OUTPUT_TOKENS
  • Disable: KIMCHI_MAX_OUTPUT_TOKENS=0

32k is deliberately below the largest legitimate turn observed (40,042 tokens across 106,594 successful
turns) to produce measurable truncation cost in the experiment. 64k was the zero-collateral value.

Checklist

  • I have read CONTRIBUTING.md and agree to the CLA
  • This PR links to an open issue above
  • Tests pass locally (pnpm run test)
  • Lint passes (pnpm run check)
  • Documentation updated if behavior changed

@readme-ai-writer

readme-ai-writer Bot commented Jul 27, 2026

Copy link
Copy Markdown

Documentation Changes Added

Page Section Action Summary
kimchi-cliGuides📝 UpdatedAdd documentation for the KIMCHI_MAX_OUTPUT_TOKENS environment variable and per-turn output token cap feature.

🔗 View all changes in ReadMe


Actions

  • Merge documentation branch with PR merge
  • Delete documentation branch with PR close

If neither actions are selected, on PR close/merge the docs branch in ReadMe will remain open.

@kimchi-review

kimchi-review Bot commented Jul 27, 2026

Copy link
Copy Markdown

Kimchi Code Review

Property Value
Commit fc4e783
Author @pmateusz
Files changed 0
Review status Completed
Comments 4 (1 info, 3 warning)
Duration 156s

Summary

📊 Review Score: 86/100 (overall code quality — 0 lowest, 100 highest)
⏱️ Estimated effort to review: 2/5 (1 = trivial, 5 = very complex)

🧪 Tests: yes — Good test coverage for the new behavior. src/extensions/max-output-tokens.test.ts covers env parsing (resolveMaxOutputTokens), payload clamping (applyMaxOutputTokens), headroom arithmetic, fallback prompt-size estimation, and the truncation-steering state machine. src/extensions/agents/manager/agent-runner.test.ts was updated to account for the new factory. The main gap is direct coverage of the before_provider_request hook path that wires ctx.getContextUsage/ctx.model.contextWindow into applyMaxOutputTokens.

📝 Found 4 issue(s). See inline comments for details.

What to expect

Kimchi will analyze the changes in this pull request and post:

  • A summary of the overall changes
  • Inline comments on specific lines with findings categorized by issue type

The review typically completes within a few minutes. This comment will be updated once the review is ready.

Interact with Kimchi
  • @getkimchi review — re-trigger a full review on the latest commit
  • @getkimchi summary — regenerate the PR summary
  • @getkimchi ignore — skip this PR (no review will be posted)
  • Reply to any inline comment to ask follow-up questions or request clarification
Configuration

Reviews are configured by your organization admin.
Review instructions, excluded directories, and severity thresholds can be adjusted per repository in the Kimchi dashboard.


Powered by Kimchi — AI-powered code review by CAST AI

@pmateusz
pmateusz marked this pull request as draft July 27, 2026 16:21

@kimchi-review kimchi-review 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.

📊 Review Score: 86/100 (overall code quality — 0 lowest, 100 highest)
⏱️ Estimated effort to review: 2/5 (1 = trivial, 5 = very complex)

🧪 Tests: yes — Good test coverage for the new behavior. src/extensions/max-output-tokens.test.ts covers env parsing (resolveMaxOutputTokens), payload clamping (applyMaxOutputTokens), headroom arithmetic, fallback prompt-size estimation, and the truncation-steering state machine. src/extensions/agents/manager/agent-runner.test.ts was updated to account for the new factory. The main gap is direct coverage of the before_provider_request hook path that wires ctx.getContextUsage/ctx.model.contextWindow into applyMaxOutputTokens.

📝 Found 4 issue(s). See inline comments for details.

* untouched — including any limit the caller already set, which is deliberate:
* "disabled" means "do not interfere", not "remove existing limits".
*/
export function applyMaxOutputTokens(payload: unknown, cap: number, limits: ContextLimits = {}): unknown {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️🔧 Maintainability

applyMaxOutputTokens mutates the incoming payload object in place (payload[field] = effective, payload[DEFAULT_FIELD] = effective). In a hook chain the same object may be observed by telemetry or other extensions, and callers may be surprised that their input was modified.

💡 Suggestion: Return a shallow copy instead of mutating the input, e.g. create const result = { ...payload } at the top, mutate result, and return it.

Comment thread src/extensions/max-output-tokens.ts Outdated
// the session rather than carrying a grudge from an earlier rough patch.
let consecutiveTruncations = 0

pi.on("turn_start", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️🏗️ Design

The turn_start handler resets consecutiveTruncations only when it already equals or exceeds MAX_CONSECUTIVE_TRUNCATION_STEERS, which contradicts the comment that any user-started turn is a fresh attempt. This causes truncation state to persist across interleaved user turns and can push the model to give up sooner than intended.

💡 Suggestion: Reset consecutiveTruncations = 0 unconditionally inside turn_start, or update the comment to describe the actual threshold-based behavior.

export function applyMaxOutputTokens(payload: unknown, cap: number, limits: ContextLimits = {}): unknown {
if (cap <= 0 || !isPayload(payload)) return payload

const headroom = resolveHeadroom(payload, limits)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️🐛 Bug

When headroom is below MIN_USABLE_CAP_TOKENS, applyMaxOutputTokens returns the payload untouched, leaving any pre-existing numeric max_tokens or max_completion_tokens unclamped. If another extension or caller has already set a large limit, the request can still exceed the context window and trigger the vLLM 400 the guard is meant to prevent.

💡 Suggestion: Before the early return, still iterate MAX_TOKEN_FIELDS and clamp or remove any existing numeric limit that is larger than the available headroom, so the guard covers all numeric limits.

)
})

pi.on("before_provider_request", async (event, ctx) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️🧪 Testing

The before_provider_request hook integration — reading ctx.getContextUsage, falling back to ctx.model.contextWindow, resolving the env-driven cap, and returning a clamped payload — is not directly tested. A regression in how context usage is mapped to ContextLimits would only be caught indirectly.

💡 Suggestion: Add a test using a minimal ExtensionAPI stub that fires before_provider_request with a fake ctx and payload, and asserts the returned payload contains the expected max_tokens value.

Bounds per-turn generation via a before_provider_request extension, with
automatic recovery when the cap is hit.

Context: TB-2.1 analysis (3,682 trials) found ~19.3M output tokens
generated then discarded — the GCLB BackendConfig (timeoutSec: 660)
severs any request exceeding 11min regardless of activity. The largest
discarded turn was 487k tokens. Nothing bounded this: the thinking dial
is advisory and pi never sends an output limit on the openai-completions
path (verified by pinning models to 600 in models.json with no effect).

The cap is fitted to remaining context (vLLM 400s if prompt + max_tokens
> context_window). Truncation is detected and the agent is steered into a
new turn instead of silently ending. A consecutive-truncation limit
prevents infinite looping.

Registered on subagent sessions too — they do the long exploratory work.

Default 32,000; KIMCHI_MAX_OUTPUT_TOKENS overrides, 0 disables. Deliberately
below the largest legitimate turn observed (40k/106k successful turns) to
produce measurable truncation cost; 64k was the zero-collateral value.

Does not address the 660s cut-offs themselves — a token cap is not a time
bound. 32k prevents ~18% of them.
@pmateusz
pmateusz force-pushed the experiment/max-output-tokens branch from fc4e783 to e12e869 Compare July 27, 2026 16:25
The output cap bounds everything a turn emits, so a limit strict enough to
stop runaway deliberation also severs tool calls. This counts streamed
reasoning and cuts the request when it passes KIMCHI_MAX_THINKING_TOKENS
(default 8k), which leaves acting turns untouched.

There is no server-side equivalent: the gateway accepts
`reasoning: {max_tokens}` and `thinking: {budget_tokens}` with HTTP 200 and
ignores both, and pi's thinkingBudgets is unimplemented for
openai-completions. The extension API has no stream hook either, and its one
lever — ctx.abort() — trips the run's own controller, ending the whole run
before the steering queue is polled (exit 1 under --print). So the wrapper
aborts a private controller and synthesizes a done/"length" event, leaving
the run intact and reading as an ordinary truncation.

Threshold picked from the thinking distribution across 37 stored
terminal-bench sessions: p50 229, p90 4.7k, p99 58k. 8k cuts ~6% of thinking
turns and recovers ~37% of the thinking that survives the 32k output cap.

Also switches the output-cap steer from followUp to steer + triggerTurn. The
follow-up queue drains only once the agent loop runs dry, so under ferment the
guidance waited behind long tool loops and arrived far too late or never. The
steering queue drains after every turn; triggerTurn covers the case where a
truncation left no tool calls and the run would otherwise end.

The truncation counter now resets on user input rather than turn_start:
agent_start opens every agent loop, including the one triggerTurn starts to
carry the steer, so resetting there would zero the counter on the run it is
counting and the give-up would never fire.

Verified against the live gateway on glm-5.2-fp8, minimax-m3 and kimi-k2.7 —
all three stream reasoning_content deltas, truncate at the budget, steer, and
exit 0 with the run intact.
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.

1 participant