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:
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' },
|
||||
|
||||
Reference in new issue
Block a user