From f1208c8d7241e8ef428d83ff235f5a218911b342 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 20 Aug 2026 12:25:51 +0800 Subject: [PATCH] feat(agent-core-v2): rework the title generation excerpts (#3109) * feat(agent-core-v2): rework the title generation excerpts - Rebalance the excerpt budgets toward the user's prompts (400 chars each) and trim the assistant segments (300) so titles follow the user's task instead of narrating the assistant's reply. - Cap each prompt in the default user_prompts excerpt so one long paste no longer starves the remaining prompts. - Compose the digest excerpt from the full conversation arc: every natural-language user prompt in the live window paired with its own turn's final assistant text, interleaved chronologically, with per-segment caps and a 3000-char total budget (middle turns elided). * chore: scope the title changeset to agent-core-v2 * fix(agent-core-v2): dedupe digest prompts and elide whole turns - Drop the redundant `| undefined` from the optional TitleDigestTurn.assistant per the monorepo optional-property convention. - Deduplicate user messages by id when constructing digest turns, so a prompt already in the context and still active in the queue does not produce two turns. - Elide the over-budget digest at whole-turn granularity, keeping each assistant line paired with its own user line. * docs(agent-core-v2): describe the full-arc digest in the SessionTitleSource contract --- .changeset/title-excerpt-rebalance.md | 5 ++ .../sessionTitle/agentTitlePromptSource.ts | 21 ++++-- .../agentTitlePromptSourceService.ts | 36 +++++---- .../src/session/sessionTitle/sessionTitle.ts | 7 +- .../sessionTitle/sessionTitleService.ts | 54 ++++++++++---- .../agentTitlePromptSourceService.test.ts | 73 ++++++++++++++++--- .../sessionTitle/sessionTitleService.test.ts | 63 +++++++++++++--- .../titleExcerpt.integration.test.ts | 4 +- packages/kap-server/test/sessions.test.ts | 2 +- 9 files changed, 201 insertions(+), 64 deletions(-) create mode 100644 .changeset/title-excerpt-rebalance.md diff --git a/.changeset/title-excerpt-rebalance.md b/.changeset/title-excerpt-rebalance.md new file mode 100644 index 000000000..92e139be7 --- /dev/null +++ b/.changeset/title-excerpt-rebalance.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": patch +--- + +Rework the session title excerpts: rebalance the segment budgets toward user prompts (400 chars each, assistant 300), cap each prompt in the `user_prompts` excerpt, and compose the `digest` excerpt from the full conversation arc — every natural-language user prompt in the live window paired with its own turn's final assistant text, interleaved chronologically, within per-segment caps and a 3000-char total budget (middle turns elided). diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts index 9710f5dca..9558cfdf2 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts @@ -12,14 +12,23 @@ export interface TitleTurnExcerpt { } /** - * The whole-conversation digest excerpt: the first and last natural-language - * user prompts (collapsed into one when the conversation has a single - * prompt) and the final assistant text of the latest turn. + * One turn of the whole-conversation digest: a natural-language user prompt + * paired with the final assistant text of its turn (`undefined` while that + * turn has not produced one). + */ +export interface TitleDigestTurn { + readonly user: string; + readonly assistant?: string; +} + +/** + * The whole-conversation digest excerpt: every natural-language user prompt + * in the live window, each paired with its own turn's final assistant text, + * in chronological order. The window may be post-compaction — the digest + * covers whatever the window still holds. */ export interface TitleDigestExcerpt { - readonly firstUser?: string | undefined; - readonly lastUser?: string | undefined; - readonly assistant?: string | undefined; + readonly turns: readonly TitleDigestTurn[]; } export interface IAgentTitlePromptSource { diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts index aa6836a98..2abd88f4c 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts @@ -12,6 +12,7 @@ import type { ContentPart } from '#/kosong/contract/message'; import { IAgentTitlePromptSource, type TitleDigestExcerpt, + type TitleDigestTurn, type TitleTurnExcerpt, } from './agentTitlePromptSource'; @@ -58,24 +59,27 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { async digestExcerpt(): Promise { const all = this.combinedMessages(); - const firstUserIndex = all.findIndex(isNaturalLanguagePrompt); - if (firstUserIndex < 0) return {}; - let lastUserIndex = -1; - for (let index = all.length - 1; index >= 0; index--) { - if (isNaturalLanguagePrompt(all[index]!)) { - lastUserIndex = index; - break; + const seenMessageIds = new Set(); + const userIndexes: number[] = []; + for (let index = 0; index < all.length; index++) { + const message = all[index]!; + if (!isNaturalLanguagePrompt(message)) continue; + if (message.id !== undefined) { + if (seenMessageIds.has(message.id)) continue; + seenMessageIds.add(message.id); } + userIndexes.push(index); } - const firstUser = promptMetadataTextFromUserMessage(all[firstUserIndex]!); - const lastUser = - lastUserIndex > firstUserIndex - ? promptMetadataTextFromUserMessage(all[lastUserIndex]!) - : undefined; - const assistant = - finalAssistantText(all.slice(lastUserIndex + 1)) ?? - finalAssistantText(all.slice(firstUserIndex + 1)); - return { firstUser, lastUser, assistant }; + const turns: TitleDigestTurn[] = []; + for (let i = 0; i < userIndexes.length; i++) { + const userIndex = userIndexes[i]!; + const user = promptMetadataTextFromUserMessage(all[userIndex]!); + if (user === undefined) continue; + const spanEnd = i + 1 < userIndexes.length ? userIndexes[i + 1]! : all.length; + const assistant = finalAssistantText(all.slice(userIndex + 1, spanEnd)); + turns.push({ user, assistant }); + } + return { turns }; } private combinedMessages(): ContextMessage[] { diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts index b620e60ed..d500df54b 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -6,9 +6,10 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio * - `first_turn`: the opening user prompt plus the first turn's final * assistant text; strict — unavailable until the first turn has produced * an assistant reply. - * - `digest`: first user prompt + latest user prompt + the latest turn's - * final assistant text, using whatever the (possibly compacted) window - * still holds; meant for explicit regeneration on multi-turn sessions. + * - `digest`: the whole conversation arc — every natural-language user + * prompt in the live window paired with its own turn's final assistant + * text, using whatever the (possibly compacted) window still holds; + * meant for explicit regeneration on multi-turn sessions. */ export type SessionTitleSource = 'user_prompts' | 'first_turn' | 'digest'; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index e0a956d0d..31cf63e80 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -31,11 +31,15 @@ const MAX_TITLE_INPUT_LENGTH = 1000; const MAX_TITLE_PROMPTS = 3; -const MAX_TITLE_USER_SEGMENT = 300; +const MAX_TITLE_USER_SEGMENT = 400; -const MAX_TITLE_FIRST_TURN_ASSISTANT = 600; +const MAX_TITLE_FIRST_TURN_ASSISTANT = 300; -const MAX_TITLE_DIGEST_ASSISTANT = 400; +const MAX_TITLE_DIGEST_USER_SEGMENT = 200; + +const MAX_TITLE_DIGEST_ASSISTANT = 200; + +const MAX_TITLE_DIGEST_INPUT_LENGTH = 3000; export class SessionTitleService implements ISessionTitleService { declare readonly _serviceBrand: undefined; @@ -161,7 +165,7 @@ export class SessionTitleService implements ISessionTitleService { function titleInputFromPrompts(prompts: readonly string[]): string | undefined { if (prompts.length === 0) return undefined; return prompts - .map((prompt) => `user: ${prompt}`) + .map((prompt) => `user: ${prompt.slice(0, MAX_TITLE_USER_SEGMENT)}`) .join('\n') .slice(0, MAX_TITLE_INPUT_LENGTH); } @@ -180,21 +184,43 @@ async function composeTitleInput( } if (source === 'digest') { const excerpt = await promptSource.digestExcerpt(); - const lines: string[] = []; - if (excerpt.firstUser !== undefined) { - lines.push(`user: ${excerpt.firstUser.slice(0, MAX_TITLE_USER_SEGMENT)}`); + const turns: string[][] = []; + for (const turn of excerpt.turns) { + const group = [`user: ${turn.user.slice(0, MAX_TITLE_DIGEST_USER_SEGMENT)}`]; + if (turn.assistant !== undefined) { + group.push(`assistant: ${turn.assistant.slice(0, MAX_TITLE_DIGEST_ASSISTANT)}`); + } + turns.push(group); } - if (excerpt.lastUser !== undefined) { - lines.push(`user: ${excerpt.lastUser.slice(0, MAX_TITLE_USER_SEGMENT)}`); - } - if (excerpt.assistant !== undefined) { - lines.push(`assistant: ${excerpt.assistant.slice(0, MAX_TITLE_DIGEST_ASSISTANT)}`); - } - return lines.length === 0 ? undefined : lines.join('\n'); + return elideTitleDigestTurns(turns); } return titleInputFromPrompts(await promptSource.firstUserPrompts(MAX_TITLE_PROMPTS)); } +const TITLE_DIGEST_ELISION_MARKER = '...'; + +function elideTitleDigestTurns(turns: readonly (readonly string[])[]): string | undefined { + if (turns.length === 0) return undefined; + const joined = turns.flat().join('\n'); + if (joined.length <= MAX_TITLE_DIGEST_INPUT_LENGTH) return joined; + let budget = MAX_TITLE_DIGEST_INPUT_LENGTH - TITLE_DIGEST_ELISION_MARKER.length - 2; + const head: string[] = []; + for (const line of turns[0]!) { + if (budget < line.length + 1) break; + head.push(line); + budget -= line.length + 1; + } + const tail: string[] = []; + for (let index = turns.length - 1; index >= 1; index--) { + const group = turns[index]!; + const cost = group.reduce((sum, line) => sum + line.length + 1, 0); + if (budget < cost) break; + tail.unshift(...group); + budget -= cost; + } + return [...head, TITLE_DIGEST_ELISION_MARKER, ...tail].join('\n'); +} + registerScopedService( LifecycleScope.Session, ISessionTitleService, diff --git a/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts index f02b75f54..0c5bd7472 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts @@ -164,7 +164,32 @@ describe('AgentTitlePromptSource', () => { }); }); - it('digestExcerpt anchors the first prompt and lands on the latest turn', async () => { + it('digestExcerpt counts a queued prompt already appended to the context only once', async () => { + liveMessages = [ + userMessage('one', '最早的问题'), + assistantMessage('a1', [{ type: 'text', text: '第一轮回答' }]), + userMessage('two', '进行中的问题'), + ]; + queue = { + active: { + id: 'two', + userMessageId: 'two', + createdAt: '2026-01-01T00:00:01.000Z', + state: 'running', + message: userMessage('two', '进行中的问题'), + }, + pending: [], + }; + + await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({ + turns: [ + { user: '最早的问题', assistant: '第一轮回答' }, + { user: '进行中的问题', assistant: undefined }, + ], + }); + }); + + it('digestExcerpt pairs every prompt with its own turn’s final assistant text', async () => { liveMessages = [ userMessage('u1', '最初的目标'), assistantMessage('a1', [{ type: 'text', text: '第一轮回答' }]), @@ -176,13 +201,37 @@ describe('AgentTitlePromptSource', () => { ]; await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({ - firstUser: '最初的目标', - lastUser: '最近的要求', - assistant: '最新正文', + turns: [ + { user: '最初的目标', assistant: '第一轮回答' }, + { user: '中途追问', assistant: '中间回答' }, + { user: '最近的要求', assistant: '最新正文' }, + ], }); }); - it('digestExcerpt collapses a single-prompt conversation and skips dangling questions', async () => { + it('digestExcerpt covers every turn, even with a dangling tool-only span', async () => { + liveMessages = [ + userMessage('u1', '最初的目标'), + assistantMessage('a1', [{ type: 'text', text: '第一轮回答' }]), + userMessage('u2', '第二个话题'), + assistantMessage('a2', [{ type: 'think', think: '只在思考' }]), + userMessage('u3', '第三个话题'), + assistantMessage('a3', [{ type: 'text', text: '第三轮回答' }]), + userMessage('u4', '最新的话题'), + assistantMessage('a4', [{ type: 'text', text: '最新回答' }]), + ]; + + await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({ + turns: [ + { user: '最初的目标', assistant: '第一轮回答' }, + { user: '第二个话题', assistant: undefined }, + { user: '第三个话题', assistant: '第三轮回答' }, + { user: '最新的话题', assistant: '最新回答' }, + ], + }); + }); + + it('digestExcerpt keeps a single-prompt conversation and dangling questions', async () => { liveMessages = [ userMessage('u1', '唯一的问题'), assistantMessage('a1', [{ type: 'text', text: '唯一的回答' }]), @@ -190,16 +239,18 @@ describe('AgentTitlePromptSource', () => { ]; await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({ - firstUser: '唯一的问题', - lastUser: '还没得到回复的新问题', - assistant: '唯一的回答', + turns: [ + { user: '唯一的问题', assistant: '唯一的回答' }, + { user: '还没得到回复的新问题', assistant: undefined }, + ], }); liveMessages = [userMessage('u1', '唯一的问题')]; await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({ - firstUser: '唯一的问题', - lastUser: undefined, - assistant: undefined, + turns: [{ user: '唯一的问题', assistant: undefined }], }); + + liveMessages = []; + await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({ turns: [] }); }); }); diff --git a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts index be35f4698..4f08d87f4 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -155,7 +155,7 @@ describe('SessionTitleService', () => { titlePrompts = []; promptSourceImpl = async (limit) => titlePrompts.slice(0, limit); turnExcerpt = {}; - digestExcerpt = {}; + digestExcerpt = { turns: [] }; tokenCalls = []; flagEnabled = true; providers = { 'managed:kimi-code': MANAGED_PROVIDER }; @@ -285,15 +285,14 @@ describe('SessionTitleService', () => { }); }); - it('truncates the composed title input to the total budget, keeping the head', async () => { + it('truncates each prompt to the per-prompt budget, keeping the head', async () => { titlePrompts = ['很长的输入'.repeat(400), '第二条']; await ix.get(ISessionTitleService).generateTitle(); const [, init] = fetchMock.mock.calls[0]!; const body = JSON.parse(init?.body as string) as { params: { chat_content: string } }; - expect(body.params.chat_content.startsWith('user: 很长的输入')).toBe(true); - expect(body.params.chat_content).toHaveLength(1000); + expect(body.params.chat_content).toBe(`user: ${'很长的输入'.repeat(80)}\nuser: 第二条`); }); it('returns unavailable when only a slash activation updated lastPrompt', async () => { @@ -408,11 +407,16 @@ describe('SessionTitleService', () => { const [, init] = fetchMock.mock.calls[0]!; const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } }) .params.chat_content; - expect(content).toBe(`user: ${'问'.repeat(300)}\nassistant: ${'答'.repeat(600)}`); + expect(content).toBe(`user: ${'问'.repeat(400)}\nassistant: ${'答'.repeat(300)}`); }); - it('digest composes head and tail segments, tolerating a missing reply', async () => { - digestExcerpt = { firstUser: '开场', lastUser: '最新追问', assistant: '当前进展' }; + it('digest composes every turn as interleaved user/assistant lines', async () => { + digestExcerpt = { + turns: [ + { user: '开场', assistant: '开场回答' }, + { user: '最新追问', assistant: '当前进展' }, + ], + }; await expect( ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), @@ -421,11 +425,13 @@ describe('SessionTitleService', () => { let [, init] = fetchMock.mock.calls[0]!; expect(JSON.parse(init?.body as string)).toEqual({ method: 'chat_title', - params: { chat_content: 'user: 开场\nuser: 最新追问\nassistant: 当前进展' }, + params: { + chat_content: 'user: 开场\nassistant: 开场回答\nuser: 最新追问\nassistant: 当前进展', + }, }); fetchMock.mockClear(); - digestExcerpt = { firstUser: '开场' }; + digestExcerpt = { turns: [{ user: '开场', assistant: undefined }] }; await expect( ix.get(ISessionTitleService).generateTitle({ force: true, source: 'digest' }), ).resolves.toBe('生成的标题'); @@ -436,8 +442,45 @@ describe('SessionTitleService', () => { }); }); + it('digest truncates each segment to its budget', async () => { + digestExcerpt = { + turns: [{ user: '问'.repeat(300), assistant: '答'.repeat(300) }], + }; + + await expect( + ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), + ).resolves.toBe('生成的标题'); + + const [, init] = fetchMock.mock.calls[0]!; + const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } }) + .params.chat_content; + expect(content).toBe(`user: ${'问'.repeat(200)}\nassistant: ${'答'.repeat(200)}`); + }); + + it('digest elides the middle turns when the input exceeds the total budget', async () => { + digestExcerpt = { + turns: Array.from({ length: 30 }, (_, i) => ({ + user: `第${i}个${'问'.repeat(180)}`, + assistant: `第${i}个${'答'.repeat(180)}`, + })), + }; + + await expect( + ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), + ).resolves.toBe('生成的标题'); + + const [, init] = fetchMock.mock.calls[0]!; + const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } }) + .params.chat_content; + expect(content.length).toBeLessThanOrEqual(3000); + expect(content.startsWith('user: 第0个')).toBe(true); + expect(content).toContain('\n...\n'); + expect(content.split('\n...\n')[1]?.startsWith('user: ')).toBe(true); + expect(content.endsWith(`assistant: 第29个${'答'.repeat(180)}`)).toBe(true); + }); + it('digest is unavailable when the window yields no segments at all', async () => { - digestExcerpt = {}; + digestExcerpt = { turns: [] }; await expect( ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), diff --git a/packages/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts b/packages/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts index 3c1248a13..68c201b8e 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts @@ -62,9 +62,7 @@ describe('title excerpts over the real context memory', () => { assistant: '部署完成,服务在 8080 端口', }); await expect(source.digestExcerpt()).resolves.toEqual({ - firstUser: '帮我部署这个服务', - lastUser: undefined, - assistant: '部署完成,服务在 8080 端口', + turns: [{ user: '帮我部署这个服务', assistant: '部署完成,服务在 8080 端口' }], }); }); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 0200d8217..6b251bc1f 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -646,7 +646,7 @@ describe('server-v2 /api/v1/sessions', () => { }); expect(digested.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); expect(toolsRequest?.params.chat_content).toBe( - 'user: first REST prompt\nuser: third REST prompt', + 'user: first REST prompt\nuser: second REST prompt\nuser: third REST prompt', ); });