fix(session-title): keep the first output line and bound the cap by the request

Claude wraps some titles in asterisk emphasis and writes the title, a
blank line, then an explanation when the messages say little. Joining the
text blocks made that explanation part of the saved title. Take the first
non-empty line, drop one emphasis pair around it, and ask the model for a
short best-effort title instead of an explanation.

The dispatched cap is now the smaller of the configured cap and the cap
the session's current main request recorded, so a route the deployment
capped below the shipped ceiling is never asked for more than that cap.
This commit is contained in:
Tianyi Cui committed 2026-10-06 15:12:55 +08:00
1 parent a0c7da0444
commit 6ec995d49b
2 files changed
+108 -5

No files matched your search

@@ -64,7 +64,7 @@ export interface SessionTitleLlmConfig {
readonly targetCjkCharacters: number
/** Maximum UTF-8 bytes in the final JSON-framed user prompt. */
readonly maxInputBytes: number
/** Auxiliary generation output-token cap. */
/** Auxiliary generation output-token cap, never above the cap the session's current request recorded. */
readonly maxOutputTokens: number
/** End-to-end auxiliary request deadline in milliseconds. */
readonly timeoutMs: number
@@ -191,6 +191,23 @@ function resolveRoute(
return request.route
}
/**
* Bound the configured output cap by the cap the session's current main
* request materialized, so a route the deployment capped below the configured
* value is never asked for more. A session whose header records no cap leaves
* the configured cap unchanged.
* @param config - validated model-provider policy.
* @param request - service-owned session of this title request.
* @returns the smaller of the configured cap and the logged request cap.
*/
function titleMaxTokens(
config: ResolvedSessionTitleLlmConfig,
request: SessionTitleProviderRequest,
): number {
const materialized = request.session.requestHeader()?.config.maxTokens
return materialized === undefined ? config.maxOutputTokens : Math.min(config.maxOutputTokens, materialized)
}
/** Stable language-aware system instruction shared by both provider plugins. */
function systemPrompt(config: ResolvedSessionTitleLlmConfig): string {
return [
@@ -198,9 +215,26 @@ function systemPrompt(config: ResolvedSessionTitleLlmConfig): string {
'Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.',
'Use the language of the messages.',
`Aim for about ${config.targetWords} words in non-CJK languages or ${config.targetCjkCharacters} CJK characters.`,
'If the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.',
].join('\n')
}
/** Asterisk emphasis wrapping a whole line, such as `**Title**`. */
const EMPHASIS_WRAPPER = /^(?<marker>\*{1,3})(?<inner>\S(?:.*\S)?)\k<marker>$/u
/**
* Take the title from model output: the first non-empty line, without
* asterisk emphasis that wraps that whole line. Models that disregard the
* one-line instruction put the title first and commentary after it.
*/
function titleFromOutput(text: string): string {
const line = text.split(/\r?\n/u).map(item => item.trim()).find(item => item.length > 0) ?? ''
const groups = EMPHASIS_WRAPPER.exec(line)?.groups
const marker = groups?.['marker']
const inner = groups?.['inner']
return marker === undefined || inner === undefined || inner.includes(marker) ? line : inner
}
/** Frame exact messages as JSON so user text cannot break structural delimiters. */
function frameMessages(messages: readonly SessionTitleUserMessage[]): string {
return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`
@@ -258,12 +292,13 @@ export async function generateSessionTitleWithLlm(
})]
const system = systemPrompt(config)
using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
const maxTokens = titleMaxTokens(config, request)
const options: GenerateOptions = deepFreeze({
provider: route.provider,
model: route.model,
messages,
system,
maxTokens: config.maxOutputTokens,
maxTokens,
sessionId: request.session.id,
purpose: 'session-title',
signal: callDeadline.signal,
@@ -274,7 +309,7 @@ export async function generateSessionTitleWithLlm(
route,
system,
messages,
maxTokens: config.maxOutputTokens,
maxTokens,
})
callDeadline.signal.throwIfAborted()
const assembler = new BlockAssembler()
@@ -293,7 +328,7 @@ export async function generateSessionTitleWithLlm(
.filter((block): block is Extract<(typeof blocks)[number], { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join(' ')
const title = normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER)
const title = normalizeSessionTitle(titleFromOutput(text), Number.MAX_SAFE_INTEGER)
if (title.length === 0) throw new Error('session-title-llm: title model produced no text')
return {
title,
@@ -76,11 +76,21 @@ const CONFIG = {
const TITLE_PROVIDER = SessionTitleProviderId('test-title-provider')
let nextSession = 0
function request(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
function request(
ctx: Context,
signal = new AbortController().signal,
headerMaxTokens?: number,
): SessionTitleProviderRequest {
const session = ctx.sessions.create(SessionId(`title-call-${++nextSession}`))
session.append('turn/start', {
turn: 1,
})
if (headerMaxTokens !== undefined) {
session.append('request/header', {
header: { config: { provider: 'current-route', model: 'current-model', maxTokens: headerMaxTokens } },
reason: 'initial',
})
}
const first = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'first prompt' }],
source: { kind: 'user' },
@@ -159,6 +169,7 @@ describe('generateSessionTitleWithLlm', () => {
})
expect(options.system).toContain('5 words')
expect(options.system).toContain('10 CJK characters')
expect(options.system).toContain('still return a short best-effort title')
const prompt = options.messages[0]?.content[0]
expect(prompt?.type === 'text' && prompt.text).toContain('first prompt')
expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题')
@@ -173,6 +184,38 @@ describe('generateSessionTitleWithLlm', () => {
})
})
it('bounds the dispatched output cap by a smaller cap the session request recorded', async () => {
const { ctx, adapter } = await withScript(SCRIPT)
const providerRequest = request(ctx, undefined, 8)
await expect(generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)).resolves.toMatchObject({ title: '五个字标题' })
expect(adapter.requests[0]?.maxTokens).toBe(8)
expect(providerRequest.session.snapshotEvents()
.findLast(event => event.type === 'session/title-llm-request')?.data.maxTokens).toBe(8)
})
it('keeps the configured output cap when the session request recorded a larger one', async () => {
const { ctx, adapter } = await withScript(SCRIPT)
const providerRequest = request(ctx, undefined, 4_096)
await expect(generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)).resolves.toMatchObject({ title: '五个字标题' })
expect(adapter.requests[0]?.maxTokens).toBe(32)
})
it('uses paired explicit overrides and bounds the final framed input before model dispatch', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -280,6 +323,31 @@ describe('generateSessionTitleWithLlm', () => {
)).rejects.toThrow(error)
})
it('takes the title from the first non-empty output line without wrapping emphasis', async () => {
const cases: [readonly string[], string][] = [
[['**Continuing Previous', ' Session**\n\nThe only message is "continue".'], 'Continuing Previous Session'],
[['\n *Greeting* \n'], 'Greeting'],
[['**a** and **b**\nnote'], '**a** and **b**'],
[['Use **bold** for emphasis'], 'Use **bold** for emphasis'],
[['****'], '****'],
]
for (const [deltas, title] of cases) {
const { ctx } = await withScript([
{ type: 'block-start', index: 0, blockType: 'text' },
...deltas.map((text): StreamChunk => ({ type: 'text-delta', index: 0, text })),
{ type: 'finish', reason: { kind: 'stop' } },
])
const providerRequest = request(ctx)
await expect(generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)).resolves.toMatchObject({ title })
}
})
it('rejects tool-call blocks and a successful response with no text', async () => {
const toolScript: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'tool-call' },