mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-06 23:36:06 +00:00
feat: generate session title from the first recorded prompts
Record up to three sanitized natural-language prompts in session metadata (skill / plugin activations excluded) and compose the chat_title input as order-labeled lines truncated to a 1000-char budget, falling back to lastPrompt for sessions without recorded prompts.
This commit is contained in:
parent
f66a0c592f
commit
42fe810d63
9 changed files with 181 additions and 20 deletions
|
|
@ -336,6 +336,7 @@ export interface SessionStateSnapshot {
|
|||
readonly title?: string;
|
||||
readonly isCustomTitle?: boolean;
|
||||
readonly lastPrompt?: string;
|
||||
readonly prompts?: readonly string[];
|
||||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
readonly archived: boolean;
|
||||
|
|
@ -1010,7 +1011,7 @@ export interface AgentStateSnapshot {
|
|||
'llmRequester.lastConfigLogSignature': string | undefined;
|
||||
'llmRequester.mediaDegradedTurns': Set<number>;
|
||||
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
|
||||
readonly "__@mediaStripSnapshotBrand@2671": undefined;
|
||||
readonly "__@mediaStripSnapshotBrand@2672": undefined;
|
||||
}>;
|
||||
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
|
||||
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,18 @@
|
|||
*
|
||||
* Derives title and last-prompt text from native and legacy prompt payloads,
|
||||
* persists metadata through `sessionMetadata`, and publishes live updates
|
||||
* through `event`. Shared by the native `rpc` prompt path and the v1 legacy
|
||||
* prompt adapter so both surfaces keep the same easy-title behavior.
|
||||
* through `event`. Natural-language prompts also append to the metadata's
|
||||
* bounded `prompts` list (the title-generation input); skill / plugin
|
||||
* activations only refresh `lastPrompt`. Shared by the native `rpc` prompt
|
||||
* path and the v1 legacy prompt adapter so both surfaces keep the same
|
||||
* easy-title behavior.
|
||||
*/
|
||||
|
||||
import type { IEventService } from '#/app/event/event';
|
||||
import type { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
|
||||
import {
|
||||
type ISessionMetadata,
|
||||
SESSION_META_PROMPT_LIMIT,
|
||||
} from '#/session/sessionMetadata/sessionMetadata';
|
||||
|
||||
import {
|
||||
promptMetadataTextFromContentParts,
|
||||
|
|
@ -58,16 +64,28 @@ export interface PromptMetadataUpdateTarget {
|
|||
export async function applyPromptMetadataUpdate(
|
||||
target: PromptMetadataUpdateTarget,
|
||||
text: string | undefined,
|
||||
recordPrompt = false,
|
||||
): Promise<void> {
|
||||
if (text === undefined) return;
|
||||
const current = await target.metadata.read();
|
||||
const patch: { lastPrompt: string; title?: string; isCustomTitle?: boolean } = {
|
||||
const patch: {
|
||||
lastPrompt: string;
|
||||
title?: string;
|
||||
isCustomTitle?: boolean;
|
||||
prompts?: readonly string[];
|
||||
} = {
|
||||
lastPrompt: text,
|
||||
};
|
||||
if (!current.isCustomTitle && isUntitled(current.title)) {
|
||||
patch.title = titleFromPromptMetadataText(text);
|
||||
patch.isCustomTitle = false;
|
||||
}
|
||||
// Keep the session's first few natural-language prompts for title
|
||||
// generation. Skill / plugin activations pass recordPrompt=false so command
|
||||
// text never becomes title input.
|
||||
if (recordPrompt && (current.prompts?.length ?? 0) < SESSION_META_PROMPT_LIMIT) {
|
||||
patch.prompts = [...(current.prompts ?? []), text];
|
||||
}
|
||||
await target.metadata.update(patch);
|
||||
target.eventService.publish({
|
||||
type: 'session.meta.updated',
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export class AgentRPCService implements IAgentRPCService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
await this.updatePromptMetadata(promptMetadataTextFromPayload(payload));
|
||||
await this.updatePromptMetadata(promptMetadataTextFromPayload(payload), true);
|
||||
const handle = await this.promptService.enqueue({ message: {
|
||||
role: 'user',
|
||||
content: [...payload.input],
|
||||
|
|
@ -204,7 +204,10 @@ export class AgentRPCService implements IAgentRPCService {
|
|||
await this.updatePromptMetadata(promptMetadataTextFromPluginCommand(payload));
|
||||
}
|
||||
|
||||
private async updatePromptMetadata(text: string | undefined): Promise<void> {
|
||||
private async updatePromptMetadata(
|
||||
text: string | undefined,
|
||||
recordPrompt = false,
|
||||
): Promise<void> {
|
||||
await applyPromptMetadataUpdate(
|
||||
{
|
||||
metadata: this.metadata,
|
||||
|
|
@ -212,6 +215,7 @@ export class AgentRPCService implements IAgentRPCService {
|
|||
sessionId: this.sessionContext.sessionId,
|
||||
},
|
||||
text,
|
||||
recordPrompt,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,12 +25,19 @@ export interface AgentMeta {
|
|||
|
||||
export const SESSION_META_VERSION = 2;
|
||||
|
||||
/** How many of the session's first natural-language prompts are kept. */
|
||||
export const SESSION_META_PROMPT_LIMIT = 3;
|
||||
|
||||
export interface SessionMeta {
|
||||
readonly id: string;
|
||||
readonly version?: number;
|
||||
readonly title?: string;
|
||||
readonly isCustomTitle?: boolean;
|
||||
readonly lastPrompt?: string;
|
||||
/** The session's first sanitized prompt texts (bounded by
|
||||
* `SESSION_META_PROMPT_LIMIT`), recorded for title generation. Absent on
|
||||
* documents written before the recording existed. */
|
||||
readonly prompts?: readonly string[];
|
||||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
readonly archived: boolean;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const autoTitleFlag: FlagDefinitionInput = {
|
|||
id: AUTO_TITLE_FLAG_ID,
|
||||
title: 'Auto session title',
|
||||
description:
|
||||
'Generate the session title on demand from the first prompt through the managed chat_title tool. Requires a managed Kimi Code OAuth login.',
|
||||
'Generate the session title on demand from the first prompts through the managed chat_title tool. Requires a managed Kimi Code OAuth login.',
|
||||
env: AUTO_TITLE_FLAG_ENV,
|
||||
default: false,
|
||||
surface: 'both',
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
* `sessionTitle` domain (L6) — session title generation contract.
|
||||
*
|
||||
* Defines the `ISessionTitleService` that (re)generates the session's title
|
||||
* on demand from its first prompt through the managed platform's `chat_title`
|
||||
* tool. Bound at Session scope — one instance per session.
|
||||
* on demand from its first prompts through the managed platform's
|
||||
* `chat_title` tool. Bound at Session scope — one instance per session.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
/**
|
||||
* `sessionTitle` domain (L6) — `ISessionTitleService` implementation.
|
||||
*
|
||||
* Generates the session's title from its already-sanitized first prompt
|
||||
* (`sessionMetadata`'s `lastPrompt`, secrets redacted by the prompt-metadata
|
||||
* flow) through the managed platform `/tools` `chat_title` endpoint, persists
|
||||
* Generates the session's title from its already-sanitized first prompts
|
||||
* (`sessionMetadata`'s bounded `prompts` list, secrets redacted by the
|
||||
* prompt-metadata flow; `lastPrompt` as the fallback for older documents)
|
||||
* through the managed platform `/tools` `chat_title` endpoint, persists
|
||||
* it through `sessionMetadata`, and rebroadcasts `session.meta.updated`.
|
||||
* Generation is on demand only: `generateTitle()` is the single entry point
|
||||
* (the kap-server route), gated by the `auto-title` experimental flag and a
|
||||
|
|
@ -32,13 +33,19 @@ import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
|||
import { IProviderService } from '#/kosong/provider/provider';
|
||||
import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
|
||||
import {
|
||||
ISessionMetadata,
|
||||
type SessionMeta,
|
||||
} from '#/session/sessionMetadata/sessionMetadata';
|
||||
|
||||
import { AUTO_TITLE_FLAG_ID } from './flag';
|
||||
import { ISessionTitleService } from './sessionTitle';
|
||||
|
||||
const MAX_GENERATED_TITLE_LENGTH = 200;
|
||||
|
||||
/** Total budget for the composed prompt texts sent to `chat_title`. */
|
||||
const MAX_TITLE_INPUT_LENGTH = 1000;
|
||||
|
||||
export class SessionTitleService implements ISessionTitleService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
|
|
@ -58,8 +65,9 @@ export class SessionTitleService implements ISessionTitleService {
|
|||
async generateTitle(): Promise<string | undefined> {
|
||||
if (!this.flags.enabled(AUTO_TITLE_FLAG_ID)) return undefined;
|
||||
const current = await this.metadata.read();
|
||||
if (current.lastPrompt === undefined || hasCustomTitle(current)) return undefined;
|
||||
return this.generateAndApply(current.lastPrompt);
|
||||
const input = titleInputFromMeta(current);
|
||||
if (input === undefined || hasCustomTitle(current)) return undefined;
|
||||
return this.generateAndApply(input);
|
||||
}
|
||||
|
||||
private async generateAndApply(chatContent: string): Promise<string | undefined> {
|
||||
|
|
@ -103,7 +111,7 @@ export class SessionTitleService implements ISessionTitleService {
|
|||
const result = await fetchChatTitle(
|
||||
kimiCodeToolsUrl(runtimeAuth.baseUrl),
|
||||
token,
|
||||
`user: ${chatContent}`,
|
||||
chatContent,
|
||||
{
|
||||
headers: {
|
||||
...parseKimiCodeCustomHeaders(),
|
||||
|
|
@ -143,6 +151,25 @@ function hasCustomTitle(metadata: {
|
|||
return metadata.isCustomTitle === true || typeof metadata.customTitle === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the `chat_title` input from the session's first recorded prompts
|
||||
* (order-labeled), falling back to `lastPrompt` for documents written before
|
||||
* prompt recording existed. Truncated to the total budget, keeping the head.
|
||||
*/
|
||||
function titleInputFromMeta(meta: SessionMeta): string | undefined {
|
||||
const prompts =
|
||||
meta.prompts !== undefined && meta.prompts.length > 0
|
||||
? meta.prompts
|
||||
: meta.lastPrompt !== undefined
|
||||
? [meta.lastPrompt]
|
||||
: undefined;
|
||||
if (prompts === undefined) return undefined;
|
||||
return prompts
|
||||
.map((prompt, index) => `user ${index + 1}: ${prompt}`)
|
||||
.join('\n')
|
||||
.slice(0, MAX_TITLE_INPUT_LENGTH);
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
ISessionTitleService,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,24 @@
|
|||
* - an inline image-compression caption (harness metadata placed next to
|
||||
* the image by prompt ingestion) never leaks into titles/lastPrompt,
|
||||
* whether it is a standalone text part or merged into the user's text
|
||||
* - natural-language prompts append to the bounded `prompts` metadata list
|
||||
* (the title-generation input) while skill / plugin activations do not
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { promptMetadataTextFromPayload } from '#/agent/rpc/prompt-metadata';
|
||||
import {
|
||||
applyPromptMetadataUpdate,
|
||||
promptMetadataTextFromPayload,
|
||||
type PromptMetadataUpdateTarget,
|
||||
} from '#/agent/rpc/prompt-metadata';
|
||||
import { buildImageCompressionCaption } from '#/agent/media/image-compress';
|
||||
import type { IEventService } from '#/app/event/event';
|
||||
import {
|
||||
type ISessionMetadata,
|
||||
type SessionMeta,
|
||||
type SessionMetaPatch,
|
||||
} from '#/session/sessionMetadata/sessionMetadata';
|
||||
|
||||
const CAPTION = buildImageCompressionCaption({
|
||||
original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' },
|
||||
|
|
@ -53,3 +65,56 @@ describe('promptMetadataTextFromPayload', () => {
|
|||
expect(text).not.toContain('Image compressed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPromptMetadataUpdate', () => {
|
||||
function createTarget(initial: Partial<SessionMeta> = {}) {
|
||||
let meta: SessionMeta = {
|
||||
id: 'sess-1',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
archived: false,
|
||||
...initial,
|
||||
};
|
||||
const target: PromptMetadataUpdateTarget = {
|
||||
metadata: {
|
||||
read: () => Promise.resolve(meta),
|
||||
update: (patch: SessionMetaPatch) => {
|
||||
meta = { ...meta, ...patch };
|
||||
return Promise.resolve();
|
||||
},
|
||||
} as unknown as ISessionMetadata,
|
||||
eventService: { publish: () => undefined } as unknown as IEventService,
|
||||
sessionId: 'sess-1',
|
||||
};
|
||||
return { target, readMeta: () => meta };
|
||||
}
|
||||
|
||||
it('records natural-language prompts into the bounded prompts list', async () => {
|
||||
const { target, readMeta } = createTarget();
|
||||
|
||||
await applyPromptMetadataUpdate(target, '第一条', true);
|
||||
await applyPromptMetadataUpdate(target, '第二条', true);
|
||||
|
||||
expect(readMeta().prompts).toEqual(['第一条', '第二条']);
|
||||
expect(readMeta().lastPrompt).toBe('第二条');
|
||||
expect(readMeta().title).toBe('第一条');
|
||||
});
|
||||
|
||||
it('does not record skill / plugin activations', async () => {
|
||||
const { target, readMeta } = createTarget();
|
||||
|
||||
await applyPromptMetadataUpdate(target, '/compact');
|
||||
|
||||
expect(readMeta().lastPrompt).toBe('/compact');
|
||||
expect(readMeta().prompts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stops recording once the prompt limit is reached', async () => {
|
||||
const { target, readMeta } = createTarget({ prompts: ['一', '二', '三'] });
|
||||
|
||||
await applyPromptMetadataUpdate(target, '第四条', true);
|
||||
|
||||
expect(readMeta().prompts).toEqual(['一', '二', '三']);
|
||||
expect(readMeta().lastPrompt).toBe('第四条');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ describe('SessionTitleService', () => {
|
|||
});
|
||||
|
||||
it('replaces the easy title with the generated one', async () => {
|
||||
await metadata.update({ lastPrompt: '帮我看一下这个 Go 的 nil pointer 报错' });
|
||||
await metadata.update({ prompts: ['帮我看一下这个 Go 的 nil pointer 报错'] });
|
||||
|
||||
const title = await ix.get(ISessionTitleService).generateTitle();
|
||||
|
||||
|
|
@ -195,7 +195,7 @@ describe('SessionTitleService', () => {
|
|||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
method: 'chat_title',
|
||||
params: { chat_content: 'user: 帮我看一下这个 Go 的 nil pointer 报错' },
|
||||
params: { chat_content: 'user 1: 帮我看一下这个 Go 的 nil pointer 报错' },
|
||||
});
|
||||
expect(new Headers(init?.headers as Record<string, string>).get('authorization')).toBe(
|
||||
'Bearer test-token',
|
||||
|
|
@ -209,6 +209,45 @@ describe('SessionTitleService', () => {
|
|||
expect(rebroadcast).toBeDefined();
|
||||
});
|
||||
|
||||
it('composes the title input from the recorded prompts in order', async () => {
|
||||
await metadata.update({
|
||||
prompts: ['先帮我搭一个 Vite 项目', '加上路由', '现在配一下 ESLint'],
|
||||
});
|
||||
|
||||
await ix.get(ISessionTitleService).generateTitle();
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
method: 'chat_title',
|
||||
params: {
|
||||
chat_content: 'user 1: 先帮我搭一个 Vite 项目\nuser 2: 加上路由\nuser 3: 现在配一下 ESLint',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates the composed title input to the total budget, keeping the head', async () => {
|
||||
await metadata.update({ prompts: ['很长的输入'.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 1: 很长的输入')).toBe(true);
|
||||
expect(body.params.chat_content).toHaveLength(1000);
|
||||
});
|
||||
|
||||
it('falls back to lastPrompt for sessions without recorded prompts', async () => {
|
||||
await metadata.update({ lastPrompt: '帮我看一下这个 Go 的 nil pointer 报错' });
|
||||
|
||||
await ix.get(ISessionTitleService).generateTitle();
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
method: 'chat_title',
|
||||
params: { chat_content: 'user 1: 帮我看一下这个 Go 的 nil pointer 报错' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing without a managed OAuth provider', async () => {
|
||||
delete providers['managed:kimi-code'];
|
||||
await metadata.update({ lastPrompt: 'hello' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue