From 48f46559fa05ceeeec6a04b7b11be9f25ea9a5b2 Mon Sep 17 00:00:00 2001 From: "yu.xia" Date: Sun, 11 Oct 2026 08:12:49 +0800 Subject: [PATCH 1/2] fix(core): bound session title generation with a small output limit The background title request went out with no max_tokens, so on a local thinking model it reasoned in an unbounded loop for minutes while competing with the main request for CPU (fixes #54401). Titles now get a 512-token ceiling through the same outputLimit path as primary and compaction requests; hooks can still change or remove the limit. --- packages/core/src/session/model-request.ts | 12 +++++++++--- packages/core/test/session-title.test.ts | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 3ec29fd9abbb..ad2592719206 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -55,6 +55,9 @@ const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields)) const OUTPUT_TOKEN_FALLBACK = 32_000 // No reply needs more, however much the model allows. const OUTPUT_TOKEN_MAX = 256_000 +// A title never needs more; without a bound a local thinking model can reason in circles +// for minutes on the background title request while the user waits (#54401). +const TITLE_OUTPUT_MAX = 512 // Prompt text is estimated at about 4 characters per token, which can run low on dense text such as code. const ESTIMATE_ERROR = 0.15 // Share of the window a turn leaves unused when its output limit is fitted to the room left. Providers count a few @@ -281,8 +284,9 @@ export const layer = Layer.effect( (t) => [{ description: t.description, input: JSON.parse(JSON.stringify(t.inputSchema)) }, t] as const, ), ) - // Hooks see the default output limit and may change or remove it. Titles and generate keep the provider default, - // because their reasoning is hard to budget. + // Hooks see the default output limit and may change or remove it. Titles get a small + // bound so background thinking-model runs cannot outpace the main request; generate + // keeps the provider default, because its reasoning is hard to budget. const shaped = yield* shape( { sessionID: session.id, @@ -294,7 +298,9 @@ export const layer = Layer.effect( ? { maxTokens: outputLimit(model.limit, input.inputTokens, WINDOW_HEADROOM) } : kind === "compaction" ? { maxTokens: outputLimit(model.limit, input.inputTokens) } - : {}, + : kind === "title" + ? { maxTokens: Math.min(outputLimit(model.limit, input.inputTokens), TITLE_OUTPUT_MAX) } + : {}, }, Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])), ) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index c4780334d18d..dd47d6f2eaf2 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -676,3 +676,20 @@ it.effect("preserves a manual rename completed while generation is in flight", ( expect((yield* store.get(sessionID))?.title).toBe("Manual title") }), ) + +it.effect("bounds the title request with a small output limit (#54401)", () => + Effect.gen(function* () { + yield* enableTitleAgent + const sessionID = Session.ID.make("ses_title_limit") + yield* insertSession(sessionID) + yield* prompt(sessionID, "Hello") + + const title = yield* SessionTitle.Service + yield* title.generate(sessionID) + + expect(requests).toHaveLength(1) + const maxTokens = requests[0]?.generation?.maxTokens + expect(maxTokens).toBeDefined() + expect(maxTokens!).toBeLessThanOrEqual(512) + }), +) From 1c92b939e809ca3493237b47c824e3f94ae79abc Mon Sep 17 00:00:00 2001 From: "yu.xia" Date: Thu, 22 Oct 2026 16:47:05 +0800 Subject: [PATCH 2/2] fix(core): size the title cap to leave room for a thinking budget 512 rejected title requests outright on providers where max_completion_tokens must exceed the thinking budget (Claude budget, OpenRouter, Gemini). 4096 still cuts the circular-reasoning loop from #54401 (minutes of tokens) in seconds while leaving a typical budget room to finish. --- packages/core/src/session/model-request.ts | 5 +++-- packages/core/test/session-title.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index ad2592719206..82bc5bb96749 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -56,8 +56,9 @@ const OUTPUT_TOKEN_FALLBACK = 32_000 // No reply needs more, however much the model allows. const OUTPUT_TOKEN_MAX = 256_000 // A title never needs more; without a bound a local thinking model can reason in circles -// for minutes on the background title request while the user waits (#54401). -const TITLE_OUTPUT_MAX = 512 +// for minutes on the background title request while the user waits (#54401). Sized to leave +// room for a small thinking budget, so budget-aware providers don't reject the request. +const TITLE_OUTPUT_MAX = 4_096 // Prompt text is estimated at about 4 characters per token, which can run low on dense text such as code. const ESTIMATE_ERROR = 0.15 // Share of the window a turn leaves unused when its output limit is fitted to the room left. Providers count a few diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index dd47d6f2eaf2..6ead8405c047 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -690,6 +690,6 @@ it.effect("bounds the title request with a small output limit (#54401)", () => expect(requests).toHaveLength(1) const maxTokens = requests[0]?.generation?.maxTokens expect(maxTokens).toBeDefined() - expect(maxTokens!).toBeLessThanOrEqual(512) + expect(maxTokens!).toBeLessThanOrEqual(4_096) }), )