Skip to content

Commit 41a660d

Browse files
committed
fix: cap compaction and completion max_tokens to provider limits
Compaction requests sent max_tokens equal to the model's full context window whenever maxOutputSize was not configured, which strict OpenAI-compatible providers reject with 400 invalid_request_error (e.g. "Invalid max_tokens value, the valid range of max_tokens is [1, 393216]"). Cap compaction output at 128k by default and size chat-completions caps to the remaining context window.
1 parent 63ee849 commit 41a660d

12 files changed

Lines changed: 168 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Fix context compaction failing with provider "Invalid max_tokens" errors by capping requested completion tokens to the remaining context window and a safe output ceiling instead of the full context window size.

packages/agent-core/src/agent/compaction/full.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ import {
4444

4545
export const MAX_COMPACTION_RETRY_ATTEMPTS = 5;
4646

47+
/**
48+
* Default hard cap on compaction output tokens when `maxOutputSize` is not
49+
* configured on the model alias. Without this, compaction falls back to the
50+
* full context window size, which exceeds the `max_tokens` ceiling enforced
51+
* by many OpenAI-compatible providers. 128k matches the chat-completions
52+
* ceiling applied by the OpenAI Legacy provider.
53+
*/
54+
const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024;
55+
4756
class CompactionTruncatedError extends Error {
4857
constructor() {
4958
super('Compaction response was truncated before producing a complete summary.');
@@ -299,12 +308,25 @@ export class FullCompaction {
299308
await this.triggerPreCompactHook(data, tokensBefore, signal);
300309

301310
const model = this.agent.config.model;
311+
const capability = this.agent.config.modelCapabilities;
312+
const maxContextTokens = capability.max_context_tokens;
313+
// When the model's context window is known and the user has not set
314+
// `maxOutputSize`, cap compaction output to a safe default so a large
315+
// context window does not push `max_tokens` past the provider's ceiling.
316+
// When the window is unknown (maxContextTokens === 0), leave
317+
// `maxOutputSize` unset so `resolveCompletionBudget` falls back to the
318+
// conservative unknown-context fallback.
319+
const defaultCompactionCap =
320+
maxContextTokens > 0
321+
? Math.min(maxContextTokens, DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS)
322+
: undefined;
302323
const provider = applyCompletionBudget({
303324
provider: this.agent.config.provider,
304325
budget: resolveCompletionBudget({
326+
maxOutputSize: this.agent.config.maxOutputSize ?? defaultCompactionCap,
305327
reservedContextSize: this.agent.pythinkerConfig?.loopControl?.reservedContextSize,
306328
}),
307-
capability: this.agent.config.modelCapabilities,
329+
capability,
308330
});
309331

310332
const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS);

packages/agent-core/src/agent/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ export class Agent {
311311
capability: this.config.modelCapabilities,
312312
generate: this.generate,
313313
completionBudgetConfig,
314+
usedContextTokens: () => this.context.tokenCount,
314315
});
315316
}
316317

packages/agent-core/src/agent/turn/kosong-llm.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ export interface KosongLLMConfig {
5555
* final cap is applied to each request.
5656
*/
5757
readonly completionBudgetConfig?: CompletionBudgetConfig | undefined;
58+
/**
59+
* Returns the number of context tokens already consumed by the latest
60+
* completed step (API-reported input + output). Used by chat-completions
61+
* providers to size the completion budget to the remaining context window.
62+
*/
63+
readonly usedContextTokens?: (() => number) | undefined;
5864
}
5965

6066
export class KosongLLM implements LLM {
@@ -65,6 +71,7 @@ export class KosongLLM implements LLM {
6571
private readonly provider: ChatProvider;
6672
private readonly generate: GenerateFn;
6773
private readonly completionBudgetConfig: CompletionBudgetConfig | undefined;
74+
private readonly usedContextTokens: (() => number) | undefined;
6875

6976
constructor(config: KosongLLMConfig) {
7077
this.provider = config.provider;
@@ -73,6 +80,7 @@ export class KosongLLM implements LLM {
7380
this.capability = config.capability;
7481
this.generate = config.generate ?? kosongGenerate;
7582
this.completionBudgetConfig = config.completionBudgetConfig;
83+
this.usedContextTokens = config.usedContextTokens;
7684
}
7785

7886
async chat(params: LLMChatParams): Promise<LLMChatResponse> {
@@ -98,6 +106,7 @@ export class KosongLLM implements LLM {
98106
provider: this.provider,
99107
budget: this.completionBudgetConfig,
100108
capability: this.capability,
109+
usedContextTokens: this.usedContextTokens?.(),
101110
});
102111
const options: GenerateOptionsWithRequestLogFields = {
103112
signal: params.signal,

packages/agent-core/src/utils/completion-budget.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,16 @@ export function applyCompletionBudget(args: {
7979
readonly provider: ChatProvider;
8080
readonly budget: CompletionBudgetConfig | undefined;
8181
readonly capability: ModelCapability | undefined;
82+
readonly usedContextTokens?: number;
8283
}): ChatProvider {
8384
if (args.budget === undefined) return args.provider;
8485
if (args.provider.withMaxCompletionTokens === undefined) return args.provider;
8586
const cap = computeCompletionBudgetCap({
8687
budget: args.budget,
8788
capability: args.capability,
8889
});
89-
return args.provider.withMaxCompletionTokens(cap);
90+
return args.provider.withMaxCompletionTokens(cap, {
91+
usedContextTokens: args.usedContextTokens,
92+
maxContextTokens: args.capability?.max_context_tokens,
93+
});
9094
}

packages/agent-core/test/agent/compaction/full.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1841,6 +1841,39 @@ describe('FullCompaction', () => {
18411841
expect(compactionMaxCompletionTokens).toEqual([undefined]);
18421842
});
18431843

1844+
it('uses default 128k hardCap when maxOutputSize is not configured', async () => {
1845+
let callCount = 0;
1846+
const compactionMaxCompletionTokens: unknown[] = [];
1847+
const generate: GenerateFn = async (provider, _system, _tools, _history, callbacks) => {
1848+
callCount += 1;
1849+
if (callCount === 1) {
1850+
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-default-cap');
1851+
}
1852+
if (callCount === 2) {
1853+
compactionMaxCompletionTokens.push(providerMaxCompletionTokens(provider));
1854+
return textResult('Default cap compacted summary.');
1855+
}
1856+
await callbacks?.onMessagePart?.({
1857+
type: 'text',
1858+
text: 'Recovered with default cap.',
1859+
});
1860+
return textResult('Recovered with default cap.');
1861+
};
1862+
const ctx = testAgent({ generate });
1863+
ctx.configure({
1864+
provider: CATALOGUED_PROVIDER,
1865+
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
1866+
});
1867+
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
1868+
ctx.newEvents();
1869+
1870+
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry with default cap' }] });
1871+
await ctx.untilTurnEnd();
1872+
1873+
expect(callCount).toBe(3);
1874+
expect(compactionMaxCompletionTokens).toEqual([128 * 1024]);
1875+
});
1876+
18441877
it('ignores filtered assistant placeholders when checking the retained overflow suffix', async () => {
18451878
let callCount = 0;
18461879
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {

packages/agent-core/test/agent/config-state.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ describe('ConfigState model capabilities', () => {
7474
});
7575
});
7676

77-
it('uses model max output size as the LLM completion cap', async () => {
77+
it('clamps the LLM completion cap to 128k for openai-compatible providers', async () => {
7878
let requestMaxTokens: unknown;
7979
const ctx = testAgent({
8080
generate: async (provider) => {
@@ -121,7 +121,9 @@ describe('ConfigState model capabilities', () => {
121121
signal: new AbortController().signal,
122122
});
123123

124-
expect(requestMaxTokens).toBe(384000);
124+
// maxOutputSize (384000) is clamped to the 128k ceiling applied to
125+
// OpenAI-compatible chat-completions providers.
126+
expect(requestMaxTokens).toBe(131072);
125127
});
126128

127129
it('uses session id as a provider prompt cache hint without storing it on Agent', () => {

packages/kosong/src/provider.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@ import type { TokenUsage } from './usage';
1313
*/
1414
export type ThinkingEffort = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
1515

16+
/**
17+
* Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a
18+
* provider can tighten the caller-supplied cap to its own transport
19+
* constraints.
20+
*/
21+
export interface MaxCompletionTokensOptions {
22+
/**
23+
* Tokens already consumed by the current context (API-reported input +
24+
* output of the latest completed step). Chat-completions providers use it
25+
* to size the cap to the remaining context window.
26+
*/
27+
readonly usedContextTokens?: number;
28+
/** Model context-window size in tokens (`max_context_size`). */
29+
readonly maxContextTokens?: number;
30+
}
31+
1632
/**
1733
* Normalized finish-reason signal indicating why a generation stopped.
1834
*
@@ -161,11 +177,19 @@ export interface ChatProvider {
161177
* budget clamped to `maxCompletionTokens`. Optional because not every
162178
* backend benefits from a client-computed cap.
163179
*
180+
* When `options` are provided, implementations may further tighten the cap
181+
* based on their own transport constraints — e.g. chat-completions
182+
* endpoints size the cap to the remaining context window
183+
* (`maxContextTokens - usedContextTokens`) and/or clamp to a fixed ceiling.
184+
*
164185
* Implementations MUST NOT mutate or replace internal HTTP clients on the
165186
* returned clone — the clone is expected to share transport state with the
166187
* original. See `PythinkerChatProvider._clone()` for the rationale.
167188
*/
168-
withMaxCompletionTokens?(maxCompletionTokens: number): ChatProvider;
189+
withMaxCompletionTokens?(
190+
maxCompletionTokens: number,
191+
options?: MaxCompletionTokensOptions,
192+
): ChatProvider;
169193
/** Upload a video and return a content part that can be sent to this provider. */
170194
uploadVideo?(input: string | VideoUploadInput, options?: GenerateOptions): Promise<VideoURLPart>;
171195
}

packages/kosong/src/providers/openai-legacy.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
ChatProvider,
44
FinishReason,
55
GenerateOptions,
6+
MaxCompletionTokensOptions,
67
ProviderRequestAuth,
78
StreamedMessage,
89
ThinkingEffort,
@@ -48,6 +49,13 @@ import {
4849
// arms can be overridden by an explicit `reasoningKey` on the provider config.
4950
const KNOWN_REASONING_KEYS = ['reasoning_content', 'reasoning_details', 'reasoning'] as const;
5051
const DEFAULT_OUTBOUND_REASONING_KEY = KNOWN_REASONING_KEYS[0];
52+
53+
/**
54+
* Hard upper bound on `max_tokens` for OpenAI-compatible chat-completions
55+
* endpoints. Many third-party providers reject `max_tokens` above this limit
56+
* (the documented range is `[1, 131072]`).
57+
*/
58+
const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024;
5159
const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
5260
normalize: (id) => sanitizeToolCallId(id, 64),
5361
maxLength: 64,
@@ -645,8 +653,20 @@ export class OpenAILegacyChatProvider implements ChatProvider {
645653
return clone;
646654
}
647655

648-
withMaxCompletionTokens(maxCompletionTokens: number): OpenAILegacyChatProvider {
649-
return this.withGenerationKwargs(completionTokenKwargs(this._model, maxCompletionTokens));
656+
withMaxCompletionTokens(
657+
maxCompletionTokens: number,
658+
options?: MaxCompletionTokensOptions,
659+
): OpenAILegacyChatProvider {
660+
let cap = maxCompletionTokens;
661+
if (
662+
options?.usedContextTokens !== undefined &&
663+
options?.maxContextTokens !== undefined &&
664+
options.maxContextTokens > 0
665+
) {
666+
cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens);
667+
}
668+
cap = Math.min(cap, CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING);
669+
return this.withGenerationKwargs(completionTokenKwargs(this._model, Math.max(1, cap)));
650670
}
651671

652672
private _clone(): OpenAILegacyChatProvider {

packages/kosong/src/providers/pythinker.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
ChatProvider,
55
FinishReason,
66
GenerateOptions,
7+
MaxCompletionTokensOptions,
78
ProviderRequestAuth,
89
StreamedMessage,
910
ThinkingEffort,
@@ -551,8 +552,19 @@ export class PythinkerChatProvider implements ChatProvider {
551552
return this._withGenerationKwargs(kwargs);
552553
}
553554

554-
withMaxCompletionTokens(maxCompletionTokens: number): PythinkerChatProvider {
555-
return this._withGenerationKwargs({ max_completion_tokens: maxCompletionTokens });
555+
withMaxCompletionTokens(
556+
maxCompletionTokens: number,
557+
options?: MaxCompletionTokensOptions,
558+
): PythinkerChatProvider {
559+
let cap = maxCompletionTokens;
560+
if (
561+
options?.usedContextTokens !== undefined &&
562+
options?.maxContextTokens !== undefined &&
563+
options.maxContextTokens > 0
564+
) {
565+
cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens);
566+
}
567+
return this._withGenerationKwargs({ max_completion_tokens: Math.max(1, cap) });
556568
}
557569

558570
withExtraBody(extraBody: ExtraBody): PythinkerChatProvider {

0 commit comments

Comments
 (0)