Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/title-excerpt-rebalance.md
Original file line number Diff line number Diff line change
@@ -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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace the changeset with a short user-facing sentence

When this changeset is incorporated into release notes, its roughly 60-word sentence exposes internal excerpt names, per-segment limits, and elision mechanics instead of providing the required short user-facing summary. Reduce it to one concise sentence describing the title-quality improvement.

AGENTS.md reference: AGENTS.md:L85-L86

Useful? React with 👍 / 👎.

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { ContentPart } from '#/kosong/contract/message';
import {
IAgentTitlePromptSource,
type TitleDigestExcerpt,
type TitleDigestTurn,
type TitleTurnExcerpt,
} from './agentTitlePromptSource';

Expand Down Expand Up @@ -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 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 });
}
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 };
return { turns };
}

private combinedMessages(): ContextMessage[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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)}`);
}
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)}`);
const turns: string[][] = [];
for (const turn of excerpt.turns) {
const group = [`user: ${turn.user.slice(0, MAX_TITLE_DIGEST_USER_SEGMENT)}`];
Comment on lines 186 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the exported digest source contract

The exported SessionTitleSource JSDoc in sessionTitle.ts:9-11 still says that digest sends only the first prompt, latest prompt, and latest assistant response, while this branch now sends every retained turn. Callers relying on generated declarations or IDE hover therefore see a contract that contradicts the implemented request shape; update that exported description with this behavior change.

Useful? React with 👍 / 👎.

if (turn.assistant !== undefined) {
group.push(`assistant: ${turn.assistant.slice(0, MAX_TITLE_DIGEST_ASSISTANT)}`);
}
turns.push(group);
}
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '第一轮回答' }]),
Expand All @@ -176,30 +201,56 @@ 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: '唯一的回答' }]),
userMessage('u2', '还没得到回复的新问题'),
];

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: [] });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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' }),
Expand All @@ -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('生成的标题');
Expand All @@ -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' }),
Expand Down
Loading
Loading