mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 14:16:22 +00:00
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
This commit is contained in:
parent
d96b4a0149
commit
f1208c8d72
9 changed files with 201 additions and 64 deletions
5
.changeset/title-excerpt-rebalance.md
Normal file
5
.changeset/title-excerpt-rebalance.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<TitleDigestExcerpt> {
|
||||
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<string>();
|
||||
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[] {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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 端口' }],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue